Deferred & Progress Tools

Let slow tools stream progress or defer a result so the turn never freezes

Slow tools can stream progress or hand back a “resolve later” handle so a single long-running call never freezes the whole turn.

Diagram

Tool Execution Options

value

progress

defer

📞 Tool Call

🔀 Return Type

✅ Result

📡 Stream Updates

🕒 Deferred Handle

⏳ Resolve Later

Quick Start

  1. Stream progress from a tool

    Add an on_progress=None parameter. The executor detects it and wires updates through automatically — no other changes needed.

    python
    from praisonaiagents import Agent
    from praisonaiagents.tools import ToolProgress
    
    def deep_research(topic: str, on_progress=None) -> str:
        if on_progress:
            on_progress(ToolProgress("searching sources..."))
            on_progress(ToolProgress("summarising..."))
        return f"Report on {topic}"
    
    agent = Agent(
        name="Researcher",
        instructions="Answer research questions.",
        tools=[deep_research],
    )
    agent.start("Research quantum computing trends")
  2. Defer a long-running job

    Return defer(...) and the model sees the note immediately — no blocking on a 10-minute render.

    python
    from praisonaiagents import Agent
    from praisonaiagents.tools import defer, DeferredToolResult
    
    render_queue = {}
    
    def render_video(script: str) -> DeferredToolResult:
        job_id = "job-123"
        render_queue[job_id] = script
        return defer(
            note=f"Started render job {job_id}. I'll post the video when it's ready.",
            handle_id=job_id,
        )
    
    agent = Agent(name="Studio", instructions="Render videos.", tools=[render_video])
    agent.start("Render the intro sequence")

    Later, call resolve_deferred(job_id, video_url) from the render-complete callback to deliver the result — see Resolving a Deferred Result.


How It Works

The executor inspects each tool’s signature, forwards progress it emits, and records a deferred handle without blocking.

Diagram
ToolTool ExecutorAgentUserToolTool ExecutorAgentUserRequestrun tool callinspect signature (on_progress?)call(args, on_progress=_emit)ToolProgress(“step 1”)forward stamped progressDeferredToolResult(handle, note)ToolResult(result=note, is_deferred=True)continue conversation
BehaviourGuarantee
Backward compatibleon_progress is only passed to tools whose signature accepts it.
Native asyncasync def tools are awaited automatically.
Non-blocking deferA defer(...) return surfaces the note to the model immediately.
Safe channelsA failing on_progress callback is swallowed; the tool keeps running.
Source stampedEach ToolProgress is tagged with its tool_call_id and function_name.

Which return type do I choose?

Pick the simplest option that fits how long your tool runs.

Diagram

< 5s

5s – few minutes

Unsupported markdown: blockquote

Yes

No

How long will the tool take?

Return a plain value

Can you emit meaningful checkpoints?

Return defer(…)

Emit ToolProgress via on_progress

Emit a single ToolProgress + return value


Configuration Options

ToolProgress describes a single incremental update a tool emits while working.

OptionTypeDefaultDescription
textstrrequiredHuman-readable progress message.
idOptional[str]NoneStable id so a channel can edit a single draft message.
replaceboolTrueIf True, replaces the prior draft; else appends.
tool_call_idOptional[str]NoneSource tool call id — stamped by the executor.
function_nameOptional[str]NoneSource tool name — stamped by the executor.

DeferredToolResult is a handle a tool returns when it kicks off background work.

OptionTypeDefaultDescription
handle_idstrrequiredIdentifier for the background job, used to resolve later.
notestr"started; will resolve later"Message shown to the model now.

The defer() factory builds a DeferredToolResult; handle_id defaults to a generated uuid.uuid4().hex when omitted.

OptionTypeDefaultDescription
notestr"started; will resolve later"Message surfaced to the model immediately.
handle_idOptional[str]NoneJob id; auto-generated when omitted.

The enriched ToolResult carries these extra fields alongside result.

Field / propertyTypeDescription
progressList[ToolProgress]All progress updates the tool emitted during this call.
deferredOptional[DeferredToolResult]Non-None when the tool returned a defer(...) handle.
is_deferredboolTrue iff deferred is not None.
structured_errorOptional[Dict]{"error": True, "type": ..., "message": ..., "tool": ...} on failure, else None.

The praisonaiagents.tools public deferred-resolver API delivers a background result back into the conversation once the work finishes.

SymbolSignature / kindDescription
register_deferred(handle_id, on_resolved, session_id=None)functionRegister a callback for a handle on the process-default resolver. Overwrites any existing callback. The callback fires as on_resolved(handle_id, value, session_id).
resolve_deferred(handle_id, value)functionFire the callback with value and drop the registration. Buffers the value when no callback is registered yet, then delivers it on the next register. Returns True if a callback fired now.
get_deferred_resolver()functionReturn the process-default DeferredResolver the run loops use.
DeferredResolverclassThe registry (a lock + dict) with register / register_if_absent / resolve / is_pending / cancel.

Native OpenAI-SDK parity is available as of the PraisonAI release that includes the #3967 fix and later.


Resolving a Deferred Result

Call resolve_deferred(handle_id, value) when the background job finishes — the value is re-injected into the same conversation as a tool response.

Diagram

Deferred Resolution

🕒 Tool returns defer(…)

💬 Note surfaces now

🤖 Agent continues

⚙️ Background finishes

📨 resolve_deferred(id, value)

✅ Re-injected into agent.chat_history

A tool returns defer(...) immediately; a background thread calls resolve_deferred(...) with the same handle_id when it is done.

python
import threading
from praisonaiagents import Agent
from praisonaiagents.tools import defer, DeferredToolResult, resolve_deferred

def _run_render(job_id: str) -> None:
    video_url = f"https://cdn.example.com/{job_id}.mp4"
    resolve_deferred(job_id, video_url)

def render_video(script: str) -> DeferredToolResult:
    job_id = "job-123"
    threading.Thread(target=_run_render, args=(job_id,)).start()
    return defer(note=f"Rendering {job_id}; I'll post the link when it's ready.", handle_id=job_id)

agent = Agent(name="Studio", instructions="Render videos.", tools=[render_video])
agent.start("Render the intro sequence")

The run loop registers the handle automatically on both the LiteLLM path and the native OpenAI-SDK path, so tool authors only call resolve_deferred(...) from the completion callback — no LLM wiring needed.

Inspect agent.chat_history to confirm the resolved value landed on the durable transcript:

python
assert any("[deferred:" in (m.get("content") or "") for m in agent.chat_history)

The two-turn cycle: turn 1 the tool returns defer(...); a background thread calls resolve_deferred(...) while the user is idle; turn 2 the user asks a follow-up and the agent replies using the resolved value now present in agent.chat_history.

Deferred results under switch_model()

Swapping the model mid-conversation keeps deferred resolution wired — switch_model(...) re-attaches the Agent’s history callback to the new LLM instance.

python
from praisonaiagents import Agent
from praisonaiagents.tools import defer, DeferredToolResult, resolve_deferred

def render_video(script: str) -> DeferredToolResult:
    return defer(note="Rendering; I'll post the link when ready.", handle_id="job-123")

agent = Agent(name="Studio", instructions="Render videos.", tools=[render_video])
agent.chat("Render the intro sequence")

agent.switch_model("openai/gpt-4o-mini")   # re-wires the deferred callback
resolve_deferred("job-123", "https://cdn.example.com/job-123.mp4")

# Lands on agent.chat_history, visible on the next turn:
assert any("[deferred:" in (m.get("content") or "") for m in agent.chat_history)
agent.chat("Post the finished video link.")

When does the resolver fire?

The resolver handles the job finishing after registration, before registration (buffered), or being cancelled.

The same sequence applies whether the agent runs on the LiteLLM loop or the native OpenAI-SDK loop.

Diagram
Background jobDeferredResolverLLM run loopToolBackground jobDeferredResolverLLM run loopToolCase A — background finishes AFTER registerCase B — background finishes BEFORE register (buffered)Case C — cancelledreturn defer(note, handle_id)register_if_absent(handle_id, cb)resolve_deferred(handle_id, value)cb(value) — re-injected into chat_historyresolve_deferred(handle_id, value)return defer(note, handle_id)register_if_absent(handle_id, cb)cb(value) — buffered value delivered nowreturn defer(note, handle_id)cancel(handle_id)resolve_deferred(handle_id, value) — dropped

Common Patterns

Async tool with progress

An async def tool is awaited natively — no asyncio.run wrapper needed.

python
import asyncio
from praisonaiagents import Agent
from praisonaiagents.tools import ToolProgress

async def deep_research(topic: str, on_progress=None) -> str:
    if on_progress:
        on_progress(ToolProgress("searching..."))
    await asyncio.sleep(0)
    if on_progress:
        on_progress(ToolProgress("summarising..."))
    return f"Report on {topic}"

agent = Agent(name="Researcher", instructions="Research topics.", tools=[deep_research])
agent.start("Research battery chemistry breakthroughs")

Deferred job resolved by handle

Return defer(...) now, then resolve the job later by its handle_id.

python
from praisonaiagents import Agent
from praisonaiagents.tools import defer, DeferredToolResult, resolve_deferred

jobs = {}

def enqueue_report(topic: str) -> DeferredToolResult:
    handle = defer(note=f"Building report on {topic}.", handle_id=f"report-{topic}")
    jobs[handle.handle_id] = {"topic": topic, "status": "running"}
    return handle

def on_report_done(handle_id: str, content: str) -> None:
    # Re-injects `content` into the same conversation as a tool response.
    resolve_deferred(handle_id, content)

agent = Agent(name="Analyst", instructions="Build reports.", tools=[enqueue_report])
agent.start("Build a market report on EVs")

Structured error inspection

Read structured_error to get the error type and message instead of a flattened string.

python
from praisonaiagents.tools.call_executor import (
    ToolCall,
    SequentialToolCallExecutor,
)

def execute(name, args, cid):
    raise ValueError("boom")

result = SequentialToolCallExecutor().execute_batch(
    [ToolCall(function_name="risky", arguments={}, tool_call_id="id-1")],
    execute,
)[0]

print(result.structured_error)
# {"error": True, "type": "ValueError", "message": "boom", "tool": "risky"}

Best Practices

Backward compatible by default

Tools without an on_progress parameter are called the old way. No change is needed unless you want progress — the executor auto-detects the parameter via inspect.signature.

Never let the UI break your tool

The executor swallows exceptions raised by an on_progress callback and keeps the tool running. A broken UI channel never kills a tool call.

Stamp your own source only when proxying

The executor stamps tool_call_id and function_name automatically. Only set them yourself when you relay updates from another tool.

Prefer defer() over blocking on external systems

Job queues, video renders, and batch pipelines belong behind a defer() handle so the turn continues while the work runs.

Same behaviour on both LLM backends

defer() and resolve_deferred() work the same on the LiteLLM loop and the native OpenAI-SDK loop. Background jobs that finish after the current turn returns still land in the agent’s chat_history and reach the next turn.


Related