Dynamic Tool Schemas
Update tool schemas at runtime so agents always see current parameter limits and descriptions
from praisonaiagents import Agent, tool
@tool
def search(query: str, limit: int = 10) -> str:
return f"Results for {query} (limit={limit})"
agent = Agent(name="assistant", tools=[search])
agent.start("Find recent papers on context compaction.")Dynamic tool schemas let your tool’s parameter limits and descriptions reflect live configuration, so the agent always sees the truth.
The user invokes the agent; dynamic schema overrides refresh parameter limits before the LLM sees the tool definition.
Quick Start
- Simple Agent with Dynamic Tool
Use the
@tooldecorator withdynamic_schema_overridesto create a tool whose schema changes based on runtime state.python from praisonaiagents import Agent, tool # Runtime configuration that can change current_limit = {"max_concurrent": 1} @tool( dynamic_schema_overrides=lambda base: { **base, "function": { **base["function"], "description": f"Delegate a task. Current concurrency limit: {current_limit['max_concurrent']}", "parameters": { **base["function"]["parameters"], "properties": { **base["function"]["parameters"]["properties"], "priority": { "type": "integer", "minimum": 1, "maximum": current_limit["max_concurrent"], "description": f"Task priority (1-{current_limit['max_concurrent']})" } } } }, } ) def delegate_task(task: str, priority: int = 1) -> str: return f"Delegated: {task} (priority: {priority})" agent = Agent( name="Coordinator", instructions="Delegate work to sub-agents using available priority levels.", tools=[delegate_task], ) # The schema reflects current limits current_limit["max_concurrent"] = 5 # Schema updates on next agent turn agent.start("Delegate three research tasks with different priorities")The user delegates research; dynamic tool schemas expose only the fields each sub-task needs.
- Using BaseTool Subclass
For more control, subclass
BaseTooland provide the override in the constructor.python from praisonaiagents import Agent, BaseTool class ConfigurableSearchTool(BaseTool): def __init__(self, api_keys_available: dict): self.api_keys_available = api_keys_available super().__init__( dynamic_schema_overrides=self._update_schema_for_available_sources ) def _update_schema_for_available_sources(self, base_schema): # Filter enum options based on available API keys available_sources = [ source for source, has_key in self.api_keys_available.items() if has_key ] return { **base_schema, "function": { **base_schema["function"], "parameters": { **base_schema["function"]["parameters"], "properties": { **base_schema["function"]["parameters"]["properties"], "source": { "type": "string", "enum": available_sources, "description": f"Search source. Available: {', '.join(available_sources)}" } } } } } def run(self, query: str, source: str) -> str: return f"Searched '{query}' using {source}" # Configure available APIs search_tool = ConfigurableSearchTool({ "google": True, "bing": False, "duckduckgo": True }) agent = Agent( name="Researcher", tools=[search_tool] ) agent.start("Research the latest AI developments")
How It Works
The override function is called every time the agent needs tool definitions, ensuring schemas always reflect current runtime state.
| Component | Purpose |
|---|---|
| Override Function | Updates schema based on current configuration |
| Registry | Manages tools and applies overrides on each read |
| Base Schema | Original tool parameter structure |
| Runtime State | Current limits, API keys, or other dynamic values |
Filtering the advertised schema
get_tool_definitions() accepts an optional permission_resolver= — a (tool_name) -> bool callable that drops a tool from the advertised schema when it returns False (deny = hide). Passing None (the default) advertises every available tool, so existing calls stay fully backward-compatible.
from praisonaiagents.tools.registry import get_tool_definitions
from praisonaiagents.permissions import PermissionManager
mgr = PermissionManager()
mgr.load_rules_from_config({"write_file": "deny"})
# Deny hides the tool from the advertised schema
defs = get_tool_definitions(permission_resolver=lambda name: not mgr.is_denied(name))| Arg | Type | Default | Description | |
|---|---|---|---|---|
permission_resolver | Callable[[str], bool] \ | None | None | Returns False to drop a tool from the schema (deny = hide). Fail-open on error. |
Common Patterns
Pattern A: Concurrency Limits
Reflect runtime concurrency settings in parameter constraints.
import os
@tool(
dynamic_schema_overrides=lambda base: {
**base,
"function": {
**base["function"],
"parameters": {
**base["function"]["parameters"],
"properties": {
**base["function"]["parameters"]["properties"],
"max_workers": {
"type": "integer",
"maximum": os.cpu_count(),
"description": f"Max workers (system has {os.cpu_count()} CPUs)"
}
}
}
}
}
)
def parallel_process(data: list, max_workers: int = 1) -> list:
return [f"processed_{item}" for item in data]Pattern B: API Key Filtering
Show only services with valid API keys in enum options.
import os
def get_available_models():
available = []
if os.getenv("OPENAI_API_KEY"):
available.extend(["gpt-4", "gpt-3.5-turbo"])
if os.getenv("ANTHROPIC_API_KEY"):
available.extend(["claude-3-opus", "claude-3-sonnet"])
return available
@tool(
dynamic_schema_overrides=lambda base: {
**base,
"function": {
**base["function"],
"parameters": {
**base["function"]["parameters"],
"properties": {
**base["function"]["parameters"]["properties"],
"model": {
"type": "string",
"enum": get_available_models(),
"description": "Available AI models"
}
}
}
}
}
)
def generate_text(prompt: str, model: str) -> str:
return f"Generated text using {model}: {prompt}"Pattern C: Live Status Updates
Include current quotas or status information in tool descriptions.
@tool(
dynamic_schema_overrides=lambda base: {
**base,
"function": {
**base["function"],
"description": f"{base['function']['description']} (Quota: {get_remaining_quota()}/1000)"
}
}
)
def api_call(endpoint: str) -> dict:
return {"result": f"Called {endpoint}"}Best Practices
Keep Override Functions Fast and Pure
Override functions run on every schema read, so avoid expensive operations like network calls or file I/O. Cache expensive computations and update them periodically rather than computing on each schema request.
# Good: Fast computation
@tool(dynamic_schema_overrides=lambda base: update_cpu_count(base))
# Avoid: Slow network calls in override
@tool(dynamic_schema_overrides=lambda base: fetch_api_limits_from_server(base))Return Fresh Dict Objects
Always return a new dictionary rather than mutating the input schema. The base schema is already a deep copy, but your modifications should create new objects to avoid side effects.
# Good: Create new structure
def my_override(base_schema):
return {
**base_schema,
"function": {
**base_schema["function"],
"description": "Updated description"
}
}
# Avoid: Mutating input
def bad_override(base_schema):
base_schema["function"]["description"] = "Updated" # Don't mutate
return base_schemaHandle Failures Gracefully
If your override function raises an exception, the tool falls back to its base schema with a warning logged. Don’t rely on the override for critical functionality—it should enhance the schema, not make it functional.
def safe_override(base_schema):
try:
return update_with_live_config(base_schema)
except Exception as e:
# Log the error but don't re-raise
logging.warning(f"Schema override failed: {e}")
return base_schema # Graceful fallbackUse Decorator Form for Simple Functions
Prefer the @tool(dynamic_schema_overrides=...) decorator for plain functions. Only subclass BaseTool when you need richer lifecycle methods or complex state management.
# Preferred: Simple decorator
@tool(dynamic_schema_overrides=lambda base: update_schema(base))
def my_function(x: str) -> str:
return x
# Use subclass only when needed
class ComplexTool(BaseTool):
def __init__(self):
super().__init__(dynamic_schema_overrides=self.complex_override)