Client-Agent Orchestration as a Tool-Calling Loop¶
Date: 2026-09-30
Status: Implemented (replaces the compile-then-execute planner)
Scope: agents/client-agent/ — how a user turn becomes specialist skill invocations.
1. Problem¶
The client-agent planned every request up front, before any specialist ran, through a chain of serial LLM calls:
| # | Stage | Module |
|---|---|---|
| 1 | Turn resolution (new task / repeat / discuss / clarify) | planner.resolve_turn |
| 2 | Capability-blind task decomposition | planner.decompose_task |
| 3 | Dependency assignment between operations | planner.decompose_task |
| 4 | Planning-tool loop (≤ 8 turns): select capabilities, map operation coverage, set steps, finalize | planner.compile_plan_with_tools |
| 5 | Schema-bound parameter extraction | parameter_extraction.extract_parameters_with_schemas |
| 6 | Capability-metadata parameter derivation | parameters.resolve_params_via_capability_metadata |
| 7 | Per-step parameter re-resolution at execution time | execution._resolve_invocation_params |
That is 6–12 serial LLM calls before the first specialist starts. The design also failed on simple requests, for two structural reasons:
- Decomposition could not see the skills.
task_decomposition_system.txttold the model to split the request "without considering what tools or capabilities may be available".planner_system.txtthen declared the operation list immutable. It also required each operation to map to exactly one skill. So "Create a PNG image with the word Popelnase" became one operation. No single skill produces a PNG with text, so the request was marked unsupported, even thoughcreate_svg_artifact→convert_svg_to_pngdoes exactly that. - The plan was fixed before any results existed. Step n+1's inputs are usually
step n's outputs, such as an artifact id. A plan compiled up front cannot bind them,
which produced the
runtime_required_params,collect_chain_required_paths, deferred paths, and "requires internal runtime inputs" fallbacks. Explicit chains like "…, then convert it to png" fell into those gaps.
2. Decision¶
Replace planning with a single tool-calling loop, the standard agent pattern:
system prompt + transcript + tools (one per skill, one per workflow template, ask_user)
│
▼
LLM turn ──► tool calls? ──no──► final answer (end of turn)
▲ │ yes
│ ▼
│ validate → [pause for user if needed] → invoke specialists via LiteLLM A2A
│ │
└──── tool results (summary + artifact ids) appended to transcript
The model sees every skill's description and input schema as a tool. It also sees everything that has happened so far: user messages, earlier tool calls, and their results with artifact ids. It decides the next call with fully parameterized arguments, after the previous call's result is known. Decomposition, chaining, parameter extraction and data flow between steps all become ordinary tool-calling behavior.
"Create a PNG image with the word Popelnase" becomes:
assistant → create_svg_artifact(word="Popelnase")
tool → {"status": "succeeded", "artifacts": [{"artifact_id": "a1", "mime_type": "image/svg+xml", ...}]}
assistant → convert_svg_to_png(artifact_id="a1")
tool → {"status": "succeeded", "artifacts": [{"artifact_id": "a2", "mime_type": "image/png", ...}]}
assistant → "Here is the PNG image with the word Popelnase."
3. Design¶
3.1 Modules¶
| Module | Role |
|---|---|
orchestrator/agent_loop.py |
The loop: tool catalog, LLM turns, call validation, pausing and resuming, execution, result compaction. |
orchestrator/chat_flow.py |
Thin entry point: slash commands, state load/create, maps a ConversationRequest (query / answers / approval / rejection) onto the loop and a LoopOutcome onto a ConversationResponse. |
orchestrator/skills.py |
Unchanged: invoke_skill (A2A via LiteLLM, Task Graph tracking, streaming status, long-running detach). |
workflow_library/ |
Unchanged engine. Each compiled template is exposed as one tool and executed deterministically by execute_workflow. |
prompts/orchestrator_system.txt |
The system prompt. |
Deleted: planner.py, planner_models.py, orchestrator/planning_flow.py,
orchestrator/parameter_extraction.py, orchestrator/parameters.py,
orchestrator/execution.py, the planning and extraction prompts,
artifact_handling.collect_chain_required_paths / resolve_artifact_inputs,
workflow_library/matcher.py, and the agent.py monkeypatch compatibility layer.
3.2 Tools¶
- Skill tools: one per registered capability (
CapabilityCache). The name is the skill id, sanitized to^[a-zA-Z0-9_-]{1,64}$. The description is the skill description, usage notes, and "requires approval" when it applies. Parameters are the skill'sinput_schema, with: - vendor keywords (
x_*,x-*) stripped, so the provider sees plain JSON Schema; - artifact-reference fields (
x_artifact_ref) described as "artifact_id of an existing conversation artifact". The user is never asked for one on a form. A missing required artifact reference is an error result that sends the model to the catalog or to the upstream skill; artifacts/conversation_idremoved, because the orchestrator attaches them.- Workflow tools: one per compiled template (
workflow_tool_name(id)). Parameters are the template'srequired_input_paths, as flat properties keyed by dotted path. ask_user: open clarification questions, given as a list of typed form fields.
3.3 Call lifecycle¶
Every tool call in an assistant message becomes a pending call with a status:
| Status | Meaning | Leaves the state when |
|---|---|---|
needs_args |
Required user-input parameters are missing (schema-derived). | The user submits the form. |
needs_answer |
ask_user question. |
The user answers (form or free text). |
needs_approval |
Skill has requires_approval or the workflow has metadata.requires_approval. |
The user approves or rejects. |
ready |
Validated, runnable. | It executes. |
waiting_input |
A specialist returned A2A INPUT_REQUIRED. |
The user answers and the same A2A task resumes. |
done |
Has a tool-result message. | — |
Validation happens before anything runs. These problems produce an immediate error
result for the model: an unknown tool, malformed JSON arguments, a schema violation
(jsonschema), an unknown artifact id, or a missing runtime input. The model can then
correct the call. A missing user-input parameter pauses with a typed form instead. The
model is told never to invent those values. Form answers go through the same validation
before the call runs.
Batch rule: if any call in an assistant message needs the user (args, answer,
approval), nothing in that message runs until every call is ready. Forms come first,
then approval. Once all calls are ready, they run concurrently (asyncio.gather), which
is how parallel work is expressed. After every call in the batch is done, the tool
results are appended in call order and the loop continues. This keeps the transcript
valid for every provider: each tool_call_id gets exactly one result before the next
LLM turn.
3.4 Pausing and resuming across turns¶
Loop state lives in the conversation record in Valkey (conversation:{id}):
{
"llm_messages": [ ... ], // OpenAI-format transcript (user / assistant+tool_calls / tool)
"pending_calls": [ ... ], // the unfinished batch, see 3.3
"turn_tool_results": { ... }, // skill_id → raw result for this user turn (response payload)
"conversation_history": [ ... ], // user-visible transcript + evidence (backend chat history)
"context": { "artifacts": [ ... ] },
"awaiting_approval": bool, "missing_info": [...], "final_report": str|null, "plan": {...}
}
How the next request is mapped:
| Incoming request | With pending calls |
|---|---|
followup_answers |
Merged into needs_args call arguments; used as the ask_user answer; merged into the params that resume waiting_input A2A tasks (same context and task id, same Task Graph node), with sources in inputSources metadata. |
plan_approved |
needs_approval → ready, then execute. |
plan_rejected |
Every unfinished call gets the result "The user rejected this call". Reply: "Plan rejected…". No LLM call. |
Free-text query |
An ask_user call is answered with the text. Every other unfinished call gets the result "Not executed: the user replied instead: …", and the loop continues. So "use 20 mm instead" while a plan awaits approval simply re-plans. |
Without pending calls, the query is appended as a user message and the loop runs.
An explicit workflow selection (/workflow slash command, catalog start endpoint)
with no free-text arguments injects a synthetic assistant tool call to that workflow.
The form inputs are its arguments, so it takes the same validate/pause/execute path
without an LLM routing decision. A /workflow command with free-text arguments
instead adds a hint to the user message, and the model fills the workflow inputs.
3.4a Concurrent turns¶
A turn detached to the background keeps running while the user sends the next one, and
conversation_store merges stores by turn instead of overwriting (merge_conversation).
The loop fits into that model as follows:
llm_messagesmerges per turn likeconversation_history. Each message is tagged with its turn'sturn_idandturn_started_atwhen first stored. A store keeps the messages of every other turn from the stored document. It takes this turn's messages from its own copy, as one contiguous block ordered by turn start.llm_viewrepairs the merged transcript before every LLM call. A paused batch resumes in a later request, which is a new turn, so another turn's block can land between a call and its result. A superseded pending call may never get a result.llm_viewputs each call's stored result directly after its assistant message. Where a call has no result, it substitutes a "not executed" one. It drops results with no call and strips the bookkeeping tags.- Unfinished
pending_callscount as pending input in the newest-turn ownership rule (_leaves_pending_input). So a newer turn that left no form or plan does not overwrite a late turn's open one. - Restore fidelity: a pause's
followup_questions/planhistory entry carries the reply text ascontent. So artifacts produced before the pause are stamped on it (stamp_turn_artifacts) and restore with that reply. Theplan_approvedentry has no content, so a restored chat shows no bubble that the live UI never showed.
3.5 What goes back to the model¶
A tool result is compact JSON:
{"status": "succeeded|failed|rejected|not_executed|answered",
"summary": "<answer/message/report text, ≤ 2 000 chars>",
"artifacts": [{"artifact_id": "...", "name": "...", "mime_type": "...", "description": "..."}],
"result": "<remaining JSON payload, ≤ 6 000 chars>",
"inputs": {"<name>": "<value>"} (or its JSON, ≤ 2 000 chars),
"input_sources": {"<name>": "model|user|context"},
"error": "..."}
Values supplied after the call was made (form answers for missing required parameters
or a specialist's INPUT_REQUIRED, and values resolved from context) are merged into the
call's arguments, so they are part of turn_tool_inputs and the params the agent
receives. Each call tracks input_sources: model for the model's own arguments, user
for form answers, context for values the platform filled in. When anything other than
the model supplied an input, the tool result includes those inputs (the model's own
arguments are already in its tool call) and input_sources for every argument, so the
model credits each value correctly. The sources also travel to
the agent as A2A message metadata inputSources.
Agents read every input from params and the sources with
PrometheusAgent.get_input_sources(context); there is no separate provided-inputs data part.
Artifacts produced by a call are merged into context.artifacts right away, so the next
call's artifact references validate. The full raw result is kept in turn_tool_results
and conversation_history for the response and the chat-history UI.
The system prompt lists the conversation's artifact catalog (id, name, mime type,
description) on every LLM call. Files uploaded this turn are named in the user message.
A reply to a pending call names its files in that call's tool result.
The answered result of ask_user and the not_executed result of a superseded call
carry the note.
The chat history keeps the reply text without the file note.
The system prompt also lists the newest successful inputs of each skill
(recent_skill_inputs). They come from the tool_inputs of every conversation_history
entry, so they survive the transcript window in 3.6. A follow-up such as "redo the valve
with 6 holes" can then start from inputs the model no longer sees in the transcript.
3.6 LLM client and bounds¶
- Uses the
prometheus_api.llm_clienttool-calling API (base image ≥1914b55). Tools areToolDefinitions sent withtool_choice="auto", calls come fromresponse.tool_calls(arguments_errorset on malformed JSON), and the transcript is built withassistant_message(response)/tool_result_message(call_id, content). The loop ends only when a response has no tool calls, never onfinish_reason. An empty final answer is reported as an error instead of being stored. - At most 12 LLM turns per user request. On hitting the limit, the loop ends with an error message.
- Transcript window: when
llm_messagesexceeds 80 entries, the oldest turns are dropped, always cutting at a user-message boundary so no tool result is orphaned. - Model flavor:
medium, hardcoded per platform convention. One call per step replaces 6–12 calls per request, so this is the speed lever. Switch tolargein code only if routing quality requires it.
3.7 Response mapping (unchanged wire contract)¶
| Loop outcome | ConversationResponse.status |
|---|---|
| Final text, ≥ 1 skill ran this turn | completed (message = report = text, tool_results, suggested queries) |
| Final text, no skill ran | answer |
Paused on needs_args / needs_answer / waiting_input |
needs_info with followup_questions |
Paused on needs_approval |
plan_ready with plan = the pending batch (steps = {name, agent, description} per call; a workflow call adds workflow_id, selection_source and any pinned workflow_version) |
| Loop error or turn limit | error |
The wire contract is unchanged except for one field. The conversation record no longer
carries a material_id, so ConversationSummary.material_id is removed from the backend
contract (backend/CONTRACT.md) and its TypeScript mirror. No frontend code read it.
4. Consequences¶
- Faster. One LLM call per step plus one for the answer. A single-skill request takes 2 LLM calls instead of roughly 8.
- More flexible. The model chains skills that no one declared a chain for. It adapts to results, such as errors or empty data. It also answers conversationally about earlier results, without a separate turn-resolution stage.
- Approval scope changes. Approval covers the next batch of calls, not the whole future plan, because later steps are decided after earlier results arrive. That is the honest version: the old up-front plan could not bind later inputs either.
- Auditability. Not provided yet. The Valkey transcript is trimmed, expiring operational context, not an audit record; approvals and calls will be recorded in a durable store.
- Agent cards matter more. Skill descriptions are now the model's only routing
signal. Descriptions that fought the old planner (e.g.
convert_svg_to_png's "do NOT chain this automatically") were rewritten. - Dropped planner-era metadata. Capability
provides/derivation_rulesandexecution_priorityare no longer consulted. Data flow is carried by the transcript.