Files
kamidso-back/kamidso/engine/app/openapi/api_v1/subagent/dispatcher.py
T
javadandCursor edbaa2acec Add native Didar connectors so chats can query CRM data reliably.
Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 19:26:47 +03:30

881 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sub-agent builder and parallel dispatch.
This module owns the parallel sub-agent delegation logic for the
``POST /v1/chat/react-agent`` link (design spec §4.3/§4.4, plan stages 2-4):
build_sub_react_agent construct an isolated child ReActAgent
make_dispatch_tool the ``dispatch_parallel_tasks`` tool factory
DISPATCH_PROMPT_SECTION the lead-agent delegation rubric (module const,
kept out of the oversized agentic_data_api.py)
Isolation model (spec §4.5):
Each sub-agent gets its own conv_id (``{parent}__sub_{i}`` — drives the
subprocess cwd ``pilot/tmp/{conv_id}``), its own ``react_state`` dict
(artifact bookkeeping), and its own ``GptsMemory`` (not cached). Tools are
rebuilt via ``make_react_tools`` capturing the sub-agent's own state.
Resource inheritance (定位 A, spec §4.5.1):
Stateful resources (database_connector / knowledge_resources /
connector MCP tools) are SHARED from the main agent, not rebuilt. MCP is
concurrency-safe because each call opens a fresh session (pack.py). Only
read-only MCP tools are injected — write tools are filtered out by the
catalog's ``confirm_actions`` list.
"""
import asyncio
import json
import logging
import re
import time
from typing import Any, Dict, List, Optional
from kamidso.engine.app.openapi.api_v1.native_dbgpt import (
bind_agent_prompt,
build_agent_memory,
op_snapshot_dir,
)
from kamidso.engine.app.openapi.api_v1.thinking_stream import live_thinking_display
from kamidso.engine.core._private.config import Config
from kamidso.engine.core.agent.expand.actions.react_action import Terminate
from kamidso.engine.core.agent.resource import ToolPack, tool
from kamidso.engine.core.agent.util.llm.llm import LLMConfig, LLMStrategyType
from kamidso.engine.core.agent.util.react_parser import ReActOutputParser
from kamidso.engine.core.core import PromptTemplate
from .react_tools import make_react_tools
from .result import attach_agent_attribution, extract_subagent_result
logger = logging.getLogger(__name__)
# Sub-agent step budget — smaller than the main agent (30) to bound cost.
_SUBAGENT_MAX_RETRY = 15
# Per-sub-agent wall-clock timeout (seconds).
_SUBAGENT_TIMEOUT = 600
# Max chars of a sub-agent step observation forwarded to the frontend process
# view. Keeps SSE light; full artifacts still flow via subagent.artifacts.
_SUBAGENT_OBS_MAX_CHARS = 2000
# HTML report chunks must NOT be cut to the small text cap — a truncated HTML
# document is unclosed and renders blank/garbled in the iframe. They get a much
# larger guard (still bounded so a runaway document can't flood the SSE stream).
_SUBAGENT_HTML_MAX_CHARS = 200_000
# The overview card needs a useful final summary, not the full report payload.
# Full structured outputs still travel through agent.step / the dispatch result.
_SUBAGENT_RESULT_SUMMARY_MAX_CHARS = 8_000
# Matches the tool-result wrapper {"chunks": [...]} that DB-GPT tools return.
_CHUNKS_WRAPPER_RE = re.compile(r'\{\s*"chunks"\s*:\s*\[.*?\]\s*\}', re.DOTALL)
def _parse_observation_chunks(obs: Any) -> List[Dict[str, Any]]:
"""Parse a sub-agent step observation into structured display chunks.
A single ReAct act may invoke several tools, so ``observations`` can be
SEVERAL ``{"chunks":[{output_type,content}]}`` wrappers concatenated
(sometimes with plain text between them). We extract every wrapper, unwrap
its inner chunks, and keep any in-between text — returning a flat list of
``{"output_type","content"}`` ready for the frontend renderer (markdown
tables / code / json / images), so the raw JSON string is NEVER shown
verbatim.
Image chunks are KEPT here (their ``content`` is a short ``/images/...``
URL, not bytes) so the right-panel process view can inline-render them
exactly like the main agent's ``OutputRenderer`` does. The same images
also flow via ``subagent.artifacts`` for the files tab / preview / download.
"""
if obs is None:
return []
if not isinstance(obs, str):
obs = str(obs)
text = obs.strip()
if not text:
return []
out: List[Dict[str, Any]] = []
last_end = 0
matched_any = False
for m in _CHUNKS_WRAPPER_RE.finditer(text):
matched_any = True
# Plain text sitting before this wrapper (e.g. a stray action label).
gap = text[last_end : m.start()].strip()
if gap:
out.append({"output_type": "text", "content": gap})
last_end = m.end()
try:
parsed = json.loads(m.group(0))
except Exception:
out.append({"output_type": "text", "content": m.group(0)})
continue
for c in parsed.get("chunks", []) if isinstance(parsed, dict) else []:
if not isinstance(c, dict):
continue
ot = c.get("output_type") or "text"
content = c.get("content")
if content is None:
continue
out.append({"output_type": ot, "content": content})
# Trailing text after the last wrapper (or the whole thing if no wrapper).
tail = text[last_end:].strip()
if tail:
out.append({"output_type": "text", "content": tail})
# No wrapper at all => treat the whole observation as plain text.
if not matched_any and not out:
out.append({"output_type": "text", "content": text})
return out
def _extract_sql_action_input(action_input: Any) -> Optional[str]:
"""Return the SQL query from a confirmed sql_query call.
Only the SQL string is exposed to the frontend. Other tool arguments stay
private so the sub-agent progress event cannot leak paths, HTML, or
connector credentials.
"""
parsed = action_input
if isinstance(parsed, str):
try:
parsed = json.loads(parsed)
except (TypeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
sql = parsed.get("sql")
if not isinstance(sql, str):
return None
return sql.strip().rstrip(";").strip() or None
def _extract_sql_from_llm_reply(llm_reply: Any) -> Optional[str]:
"""Recover a confirmed SQL input when ActionOutput omits action_input."""
if not isinstance(llm_reply, str) or not llm_reply.strip():
return None
try:
steps = ReActOutputParser().parse(llm_reply)
except Exception:
logger.debug("Unable to parse sub-agent ReAct reply for SQL recovery.")
return None
for step in reversed(steps):
if (step.action or "").lower() == "sql_query":
return _extract_sql_action_input(step.action_input)
return None
# Tool names a sub-agent is allowed to use. Taken from the factory's 8 tools
# plus the module-level skill tools. Intentionally EXCLUDES
# ``dispatch_parallel_tasks`` (anti-recursion fan-out) and ``todowrite``
# (sub-agents do not maintain a task list; it lives in the main loop).
_SUBAGENT_FACTORY_TOOL_NAMES = [
"load_skill",
"load_tools",
"knowledge_retrieve",
"sql_query",
"code_interpreter",
"shell_interpreter",
"execute_skill_script_file",
"html_interpreter",
]
def _subagent_tool_names(*, report_mode: bool = False) -> List[str]:
from kamidso.database_runtime.flags import agent_sees_sql_query
names = list(_SUBAGENT_FACTORY_TOOL_NAMES)
if not agent_sees_sql_query():
names = [name for name in names if name != "sql_query"]
if report_mode:
return names
return [name for name in names if name != "html_interpreter"]
_SUB_AGENT_PROMPT_TEMPLATE = """\
You are a Kamidso (کامیدسو) sub-agent executing ONE focused sub-task delegated by a lead
agent. You run in an isolated context — you CANNOT see the main conversation
history. Everything you need is in the goal (and optional context) below.
## Your sub-task goal
{goal}
{extra_context}
## ACTION SPACE (your available tools)
You operate resources ONLY through the tools listed below. You MUST use the
EXACT tool name shown here — do not invent names like ``run_sql`` / ``run_python``.
All tools are read-only; you CANNOT perform write operations. If your task
needs a write, return a recommendation for the lead agent to act instead.
Use the database / knowledge base / MCP connector named in your goal.
{{{{ action_space }}}}
## Rules
- You CANNOT delegate further (no parallel sub-tasks of your own).
- Keep your context tight; produce a concise, self-contained conclusion.
- When done, call ``terminate`` with your final result.
- If ``connector_call`` is in the action space, CRM and other business-app data
MUST be read with ``connector_call`` (semantic operation or the function named
in the goal). Never call ``http://127.0.0.1``, ``http://localhost``, or invent
paths such as ``/api/crm``, ``/api/didar``, or ``/search_deals``. Those hit
this application and return 404; they are not the business API.
## Output formatting (IMPORTANT — keep results readable)
- One query/result per step. Prefer separate ``database().query`` calls inside
``code_interpreter`` over packing many unrelated results into one print.
- NEVER concatenate multiple tables into one blob. If you DO print markdown
from ``code_interpreter``, every table row MUST be on its OWN line (``\\n``),
and there MUST be a blank line between a table and any following heading or
the next table. Do NOT glue ``| ... |### heading`` or ``| --- || row``.
- In ``code_interpreter``, use ``print(df.to_markdown(index=False))`` per table
and ``print()`` (blank line) between tables — do not hand-build one big string.
## RESPONSE FORMAT (ReAct)
Every response MUST contain exactly one Action and one Action Input.
The Action MUST be one of [{{{{ action_space_names }}}}].
- Thought: analyze status and decide the next step
- Action Intention: what this step will do (short, user-facing)
- Action: the selected tool name (exactly as listed above)
- Action Input: the JSON tool parameters (empty if none required)
When the task is complete, use exactly:
Thought: ...
Action: terminate
Action Input: {{"result": "final answer"}}
"""
def _filter_readonly_connector_tools(
connector_tool_extras: Optional[List[Any]],
connector_manager: Any = None,
) -> List[Any]:
"""Filter connector (MCP) tools down to read-only ones for sub-agents.
Write operations are identified exactly as ``ConfirmationRegistry`` does
(confirmation.py): a tool whose name appears in any catalog entry's
``confirm_actions`` list requires confirmation, i.e. it is a write. Such
tools are removed so a parallel sub-agent can never trigger concurrent
write-confirmation popups (spec §4.5.1 point 4 / §6.4).
Args:
connector_tool_extras: Flat list of ``BaseTool`` from
``_select_connector_tools`` (or None).
connector_manager: A ``ConnectorManager`` (exposes ``get_catalog()``).
When None, the catalog cannot be consulted; to stay safe we drop
ALL connector tools rather than risk injecting a write tool.
Returns:
The subset of tools that are safe (read-only) for a sub-agent.
"""
if not connector_tool_extras:
return []
if connector_manager is None:
# No catalog to classify with — fail safe: inject none.
logger.warning(
"sub-agent: connector_manager is None; dropping all %d connector "
"tools to avoid injecting a write tool",
len(connector_tool_extras),
)
return []
try:
catalog = connector_manager.get_catalog()
write_tool_names: set = set()
for entry in catalog.list():
write_tool_names.update(entry.confirm_actions or [])
except Exception as e: # pragma: no cover - defensive
logger.warning(
"sub-agent: failed to read connector catalog (%s); dropping all "
"connector tools",
e,
)
return []
readonly = []
dropped = []
for t in connector_tool_extras:
name = getattr(getattr(t, "_tool", None), "name", None) or getattr(
t, "name", None
)
if name in write_tool_names:
dropped.append(name)
else:
readonly.append(t)
if dropped:
logger.info(
"sub-agent: filtered out %d write connector tool(s): %s",
len(dropped),
dropped,
)
return readonly
def _build_sub_prompt(goal: str, extra_context: Optional[str]) -> PromptTemplate:
"""Build the sub-agent system prompt from goal + optional context."""
ctx_block = ""
if extra_context and extra_context.strip():
ctx_block = f"\n## Shared context from the lead agent\n{extra_context}\n"
text = _SUB_AGENT_PROMPT_TEMPLATE.format(goal=goal, extra_context=ctx_block)
return PromptTemplate(template=text, input_variables=[], template_format="jinja2")
async def build_sub_react_agent(
sub_goal: str,
sub_index: int,
*,
parent_conv_id: str,
llm_client: Any,
sub_model_name: Optional[str] = None,
database_connector: Any = None,
knowledge_resources: Any = None,
readonly_connector_tools: Optional[List[Any]] = None,
extra_context: Optional[str] = None,
batch_id: int = 0,
report_mode: bool = False,
native_connectors: Optional[List[Any]] = None,
user_name: Optional[str] = None,
):
"""Construct an isolated child ReActAgent for one sub-task.
Args:
sub_goal: Self-contained objective for this sub-agent.
sub_index: Index within the dispatch batch (drives conv_id / lane).
parent_conv_id: The lead agent's conv_id.
llm_client: The lead agent's ``LLMClient`` (reused, not the config).
sub_model_name: Model for the sub-agent; None => same as main model.
database_connector: Shared read-only DB connector (or None).
knowledge_resources: Shared read-only knowledge resources (or None).
readonly_connector_tools: Pre-filtered read-only MCP tools (or None).
extra_context: Optional shared background spliced into the prompt.
report_mode: When True, include ``html_interpreter`` in the child pack.
batch_id: Dispatch-batch sequence number. Folded into ``sub_conv_id``
so repeated dispatch calls in the same conversation do NOT reuse
the same working directory / agent_id (which would overwrite the
previous batch's sub-agent row and cross-contaminate artifacts).
Returns:
``(sub_agent, sub_conv_id, sub_state)``.
"""
from kamidso.engine.core.agent import AgentContext
from kamidso.engine.core.agent.expand.react_agent import ReActAgent
# (1) Independent conv_id — the workspace-isolation anchor. Drives the
# subprocess cwd pilot/tmp/{conv_id} inside code_interpreter / shell.
# The ``d{batch_id}`` segment makes each dispatch batch's sub-agents get
# their OWN directory, so batch 2's sub_0 cannot read/overwrite batch 1's
# sub_0 artifacts (a real cross-batch data-leak before this).
sub_conv_id = f"{parent_conv_id}__d{batch_id}_sub_{sub_index}"
# (2) Independent react_state — artifact bookkeeping isolation. A fresh
# dict means generated_images / image_url_map etc. never cross-write.
sub_state = {"conv_id": sub_conv_id, "file_path": None}
if native_connectors:
from kamidso.native_connectors.resolve import install_native_connectors
install_native_connectors(sub_state, native_connectors)
# (3) Independent AgentMemory — not cached in REACT_AGENT_MEMORY_CACHE,
# so the sub-agent's history never pollutes the parent's memory.
sub_agent_memory = build_agent_memory(
sub_conv_id,
cache={},
system_app=getattr(Config(), "SYSTEM_APP", None),
)
# (4) Independent tool set — factory rebuilds the 8 tools capturing
# sub_state. Shared read-only DB / knowledge resources are passed through.
# MCP/connector tools are shared objects (stateless per-call sessions),
# already filtered to read-only by the caller.
sub_tools = make_react_tools(
sub_state,
database_connector=database_connector,
knowledge_resources=knowledge_resources,
)
native_tools: List[Any] = []
if sub_state.get("native_connectors"):
from kamidso.engine.app.openapi.api_v1.tools.connector_call import (
make_connector_call,
)
native_tools.append(make_connector_call(sub_state, user_name=user_name))
sub_tool_pack = ToolPack(
[sub_tools[name] for name in _subagent_tool_names(report_mode=report_mode)]
+ (readonly_connector_tools or [])
+ native_tools
+ [Terminate()]
)
# (5) Rebuild LLMConfig reusing the client. None model => Default strategy
# (picks the same model the worker manager serves = parent's default).
if sub_model_name:
sub_llm_config = LLMConfig(
llm_client=llm_client,
llm_strategy=LLMStrategyType.Priority,
strategy_context=json.dumps([sub_model_name]),
)
else:
sub_llm_config = LLMConfig(llm_client=llm_client)
sub_context = AgentContext(
conv_id=sub_conv_id,
gpts_app_code="react_agent_sub",
gpts_app_name="ReAct-Sub",
language="zh",
enable_context_management=True,
output_dir=op_snapshot_dir(),
)
extra_goal = sub_goal
if extra_context and extra_context.strip():
extra_goal = (
f"{sub_goal}\n\nShared context from the lead agent:\n"
f"{extra_context.strip()}"
)
sub_agent = (
ReActAgent(max_retry_count=_SUBAGENT_MAX_RETRY)
.bind(sub_context)
.bind(sub_agent_memory)
.bind(sub_llm_config)
.bind(sub_tool_pack)
)
sub_agent = bind_agent_prompt(
sub_agent,
_build_sub_prompt(sub_goal, extra_context),
extra_goal=extra_goal,
)
sub_agent = await sub_agent.build()
return sub_agent, sub_conv_id, sub_state
# Lead-agent delegation rubric. Kept here as a module constant so the long
# prompt text does NOT bloat agentic_data_api.py; the main link imports and
# splices it into the full-tool system prompt (plan stage 4 / spec §4.6).
DISPATCH_PROMPT_SECTION = """\
## Task Decomposition and Parallel Execution (todowrite + dispatch_parallel_tasks)
[Run a parallel-eligibility check at the start of every round]
Before selecting the first tool, evaluate the user's task by meaning and
structure. Parallel dispatch is required when all of the following are true:
1. The task can be split into at least 2 clearly scoped subtasks, each capable
of producing a useful result on its own.
2. The subtasks have no ordering dependency; none must wait for another result.
3. Each subtask needs its own query, research, analysis, or content production
and is substantial enough to benefit from an isolated context.
4. Each subtask can be expressed as a self-contained goal with the required
database, table, knowledge base, or connector information.
When all conditions are satisfied, you MUST execute
`todowrite -> dispatch_parallel_tasks`, even if the overall task has fewer than
3 steps. Do not avoid parallel execution merely because there are only 2
independent subtasks.
Natural-language structures such as "separately", "respectively",
"simultaneously", "in parallel", or requests covering multiple objects
(regions, topics, data sources, or reports) are strong candidate signals.
Do not rely on keyword matching alone; always apply the independence criteria
above. Proactively use parallel execution when the task qualifies, even if the
user does not explicitly ask for sub-agents.
This is a mandatory two-stage workflow and the order must be preserved:
[Stage 1: Decompose] Use `todowrite` to turn the task into a structured todo
list. `todowrite` is the only entry point for decomposition; every multi-step
task must be decomposed there first.
[Stage 2: Execute] After decomposition, use `dispatch_parallel_tasks` to assign
multiple independent todo items with no ordering dependency to sub-agents in
parallel. Each sub-agent executes exactly one decomposed item. Sub-agents are
an execution mechanism after decomposition, not a decomposition tool.
[Mandatory rules]
- Never skip `todowrite` and call `dispatch_parallel_tasks` directly. A todo
list must exist first, and every dispatched subtask must correspond to an
item in that list.
- Keep exactly one todo item marked as `in_progress`. This is a progress-board
constraint only; other eligible independent `pending` items may still be
included in the same dispatch batch.
- As soon as all parallel subtasks return, call `todowrite` again and mark
those items as `completed`. The dispatch tool does not update todo state.
- Do not parallelize dependent tasks. Complete the prerequisite first, then
pass its result through `context` to the dependent item in a later round.
[Good candidates for parallel dispatch]
- Research multiple independent data sources or MCP connectors that do not
share intermediate results.
- Produce separate analyses for multiple independent objects, such as three
reports or three unrelated topics.
- Run multiple independent read-only queries or analyses against the same
database or table. For example, analyze the `users` table's country
distribution and registration-year distribution separately when neither
analysis needs the other's intermediate result.
- Execute any set of todo items that have no data dependency and can all start
immediately.
[Do not use parallel dispatch]
- Steps share intermediate results, cleaning state, or must reuse the same
computed metric definition.
- Todo items are dependent, such as cleaning before forecasting or when B
requires A's result.
- The task is a simple, single-step operation; execute it directly without
creating a todo list.
[Usage rules]
- Every subtask goal must be self-contained. A sub-agent cannot see the current
conversation history, so put all required information in `goal` or `context`.
- Sub-agents inherit database, knowledge-base, read-only MCP tools, and, when a
native business connector is attached, ``connector_call``. Name the required
database, knowledge base, or connector operation in every goal/context.
Do not tell a sub-agent to call localhost or invent ``/api/crm`` paths.
Sub-agents are read-only; the lead agent must perform write operations.
- Dispatch at most N subtasks per call. Excess tasks are truncated and reported;
dispatch additional batches when necessary.
- A sub-agent must never call `dispatch_parallel_tasks` recursively.
"""
def subagent_thought_event(
agent_id: str, agent_name: str, raw_text: str
) -> Optional[Dict[str, Any]]:
"""Live thought text for one sub-agent, ready for the chat stream."""
display = live_thinking_display(raw_text)
if not display:
return None
return {
"type": "agent.thinking",
"agent_id": agent_id,
"agent_name": agent_name,
"content": display,
}
def subagent_running_step_event(
agent_id: str,
agent_name: str,
round_num: int,
llm_reply: str,
) -> Optional[Dict[str, Any]]:
"""Announce the tool a sub-agent is about to run, before it returns."""
if not llm_reply:
return None
try:
steps = ReActOutputParser().parse(llm_reply)
except Exception:
return None
if not steps or not steps[0].action:
return None
action = str(steps[0].action)
if action.lower() == "terminate":
return None
return {
"type": "agent.step",
"agent_id": agent_id,
"agent_name": agent_name,
"round": round_num,
"action": action,
"intention": steps[0].action_intention or "",
"status": "running",
}
def make_dispatch_tool(
*,
parent_conv_id: str,
llm_client: Any,
sub_model_name: Optional[str] = None,
database_connector: Any = None,
knowledge_resources: Any = None,
connector_tool_extras: Optional[List[Any]] = None,
connector_manager: Any = None,
emit_event: Any,
max_parallel: int = 3,
report_mode: bool = False,
native_connectors: Optional[List[Any]] = None,
user_name: Optional[str] = None,
):
"""Build the ``dispatch_parallel_tasks`` tool bound to the lead context.
Args:
parent_conv_id: The lead agent's conv_id (sub conv_ids derive from it).
llm_client: The lead agent's ``LLMClient`` (reused for sub-agents).
sub_model_name: Sub-agent model; None => same as main model.
database_connector: Shared read-only DB connector (or None).
knowledge_resources: Shared read-only knowledge resources (or None).
connector_tool_extras: The lead agent's MCP tools (filtered read-only
here before being shared with sub-agents).
connector_manager: ``ConnectorManager`` for the read-only filter.
emit_event: ``async (payload: dict) -> None`` — injected callback that
puts SSE events on the stream queue. Keeps the dispatcher
decoupled from the queue itself (testable).
max_parallel: Hard cap on concurrent sub-agents per call.
report_mode: Forwarded to child agents so ``html_interpreter`` is
available only when the lead turn has reports enabled.
Returns:
The ``dispatch_parallel_tasks`` tool (a ``@tool``-wrapped callable).
"""
# Filter the lead agent's MCP tools to read-only ONCE, then share with
# every sub-agent (the tool objects are stateless per-call — safe to share).
readonly_connector_tools = _filter_readonly_connector_tools(
connector_tool_extras, connector_manager
)
# Per-lead-agent dispatch counter. Each ``dispatch_parallel_tasks`` call
# bumps it and folds the value into every sub-agent's ``agent_id`` /
# ``sub_conv_id``. Without this, batch 2's ``sub_0`` would reuse batch 1's
# ``sub_0`` working directory (cross-batch artifact leak) and the frontend
# ``subAgents[agent_id]`` map would overwrite the earlier row.
_dispatch_counter = 0
@tool(
description=(
"把多个【相互独立、无依赖】的子任务并行交给子 Agent 执行,返回各自"
"结果。任务包含至少 2 个可独立交付、可同时开始、且各自值得独立上下文"
"的目标时使用。同一数据库或同一张表上的独立只读分析也可以并行;只有"
"共享中间结果、计算状态或存在前后依赖时才应串行。"
f"单次最多 {max_parallel} 个子任务,超出请分批多轮调用。"
'参数: {"tasks": [{"goal": "...", "context": "...", "title": "..."}]}'
)
)
async def dispatch_parallel_tasks(tasks: list) -> str:
nonlocal _dispatch_counter
if not isinstance(tasks, list) or not tasks:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "Error: tasks 必须是非空列表",
}
]
},
ensure_ascii=False,
)
# Cap to the concurrency limit; report what was dropped (no silent cap).
accepted = tasks[:max_parallel]
dropped = len(tasks) - len(accepted)
# Stable, unique batch id for this dispatch call. Folded into every
# sub-agent's id / conv_id so repeated dispatches never collide.
_dispatch_counter += 1
batch_id = _dispatch_counter
async def run_one(idx: int, t: dict) -> dict:
started_at = time.monotonic()
agent_id = f"sub_d{batch_id}_{idx}"
title = (
t.get("title") if isinstance(t, dict) else None
) or f"子任务{idx + 1}"
goal = (t.get("goal") if isinstance(t, dict) else None) or ""
extra = t.get("context") if isinstance(t, dict) else None
await emit_event(
{
"type": "agent.start",
"agent_id": agent_id,
"agent_name": title,
"goal": goal,
"lane": idx,
"batch_id": batch_id,
}
)
execution_token = None
try:
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
bind_shared_execution,
empty_skill_execution,
reset_shared_execution,
)
execution_token = bind_shared_execution(empty_skill_execution())
agent, _cid, sub_state = await build_sub_react_agent(
goal,
idx,
parent_conv_id=parent_conv_id,
llm_client=llm_client,
sub_model_name=sub_model_name,
database_connector=database_connector,
knowledge_resources=knowledge_resources,
readonly_connector_tools=readonly_connector_tools,
extra_context=extra,
batch_id=batch_id,
report_mode=report_mode,
native_connectors=native_connectors,
user_name=user_name,
)
from kamidso.engine.core.agent import AgentMessage
# Live thoughts and tool steps go out on this agent_id channel.
# They never enter the lead loop's round_step_map, which would
# collide when several sub-agents run at once.
pending_sql_by_round: Dict[int, str] = {}
raw_thoughts: Dict[str, List[str]] = {}
last_thought: Dict[str, str] = {}
async def _sub_stream_callback(
event_type: str, payload: dict, _aid: str = agent_id
) -> None:
round_num = int(payload.get("round") or 0)
if event_type == "thinking_chunk":
piece = payload.get("delta_thinking") or payload.get(
"delta_text"
)
if not piece:
return
parts = raw_thoughts.setdefault(_aid, [])
parts.append(str(piece))
thought = subagent_thought_event(
_aid, title, "".join(parts)
)
if thought and thought["content"] != last_thought.get(_aid):
last_thought[_aid] = thought["content"]
await emit_event(thought)
return
if event_type == "thinking":
sql = _extract_sql_from_llm_reply(payload.get("llm_reply"))
if sql:
pending_sql_by_round[round_num] = sql
running = subagent_running_step_event(
_aid,
title,
round_num,
str(payload.get("llm_reply") or ""),
)
if running:
running["batch_id"] = batch_id
await emit_event(running)
return
if event_type != "act":
return
ao = payload.get("action_output") or {}
if ao.get("terminate"):
return
action = ao.get("action")
if not action or action.lower() == "terminate":
return
# Parse the raw observation into STRUCTURED display chunks
# for the right-panel process view (Devin-style). The tool
# result is a {"chunks":[...]} wrapper (or several concat'd);
# we unwrap so the frontend renders markdown tables / code /
# json / images properly instead of showing the raw JSON
# string. Images are kept inline (same as the main agent's
# OutputRenderer) AND also flow via subagent.artifacts for
# the files tab / preview / download.
raw_obs = ao.get("observations") or ao.get("content") or ""
chunks = _parse_observation_chunks(raw_obs)
# Per-chunk truncation keeps SSE light without cutting a
# table in half mid-cell. HTML report chunks are EXEMPT from
# the small text cap — truncating an HTML document yields an
# unclosed, broken ``srcDoc`` that the iframe cannot render
# ("report cut off"). They get a much larger guard instead.
# Image chunks are tiny (just a /images/... URL), so no cap.
for c in chunks:
content = c.get("content")
if not isinstance(content, str):
continue
if c.get("output_type") == "html":
limit = _SUBAGENT_HTML_MAX_CHARS
elif c.get("output_type") == "image":
continue
else:
limit = _SUBAGENT_OBS_MAX_CHARS
if len(content) > limit:
c["content"] = content[:limit] + "…(已截断)"
step_event = {
"type": "agent.step",
"agent_id": _aid,
"agent_name": title,
"batch_id": batch_id,
"action": action,
"intention": ao.get("action_intention"),
"chunks": chunks,
"round": payload.get("round"),
"status": "completed",
}
if action.lower() == "sql_query":
sql = _extract_sql_action_input(
ao.get("action_input")
) or pending_sql_by_round.pop(round_num, None)
if sql:
step_event["sql"] = sql
await emit_event(step_event)
reply = await asyncio.wait_for(
agent.generate_reply(
received_message=AgentMessage(content=goal),
sender=agent,
stream_callback=_sub_stream_callback,
),
timeout=_SUBAGENT_TIMEOUT,
)
r = extract_subagent_result(reply, sub_state, title, "done")
except asyncio.TimeoutError:
r = {
"title": title,
"status": "timeout",
"result": f"子任务超时({_SUBAGENT_TIMEOUT}s)",
"artifacts": [],
}
except Exception as e: # single failure must not break the batch
logger.warning("sub-agent %d failed: %s", idx, e, exc_info=True)
r = {
"title": title,
"status": "failed",
"result": f"执行失败: {e}",
"artifacts": [],
}
finally:
if execution_token is not None:
try:
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
reset_shared_execution,
)
reset_shared_execution(execution_token)
except (ValueError, LookupError):
pass
# Stamp the source agent on every artifact so the frontend can
# count/label per sub-agent (aggregate-card row + files-tab tag).
attach_agent_attribution(r.get("artifacts", []), agent_id, title)
r["agent_id"] = agent_id
r["agent_name"] = title
result_summary = str(r.get("result") or "")
if len(result_summary) > _SUBAGENT_RESULT_SUMMARY_MAX_CHARS:
result_summary = (
result_summary[:_SUBAGENT_RESULT_SUMMARY_MAX_CHARS]
+ "\n\n…(完整结果请查看子任务详情)"
)
await emit_event(
{
"type": "agent.done",
"agent_id": agent_id,
"batch_id": batch_id,
"status": r["status"],
"result": result_summary,
"elapsed_ms": int((time.monotonic() - started_at) * 1000),
}
)
return r
results = await asyncio.gather(*[run_one(i, t) for i, t in enumerate(accepted)])
# Artifacts go to the frontend out-of-band — NOT into the lead context.
# Each item already carries its source agent_id / agent_name (stamped
# above), so the frontend can do per-row counting and "by <agent>" labels.
all_artifacts = [a for r in results for a in r.get("artifacts", [])]
if all_artifacts:
await emit_event({"type": "subagent.artifacts", "items": all_artifacts})
# Only the compressed text conclusions are relayed to the lead LLM.
summary = "\n\n".join(
f"### {r['title']} [{r['status']}]\n{r['result']}" for r in results
)
if dropped > 0:
summary += (
f"\n\n⚠️ 另有 {dropped} 个子任务因超过并发上限未执行,"
f"请在下一轮再次调用 dispatch_parallel_tasks 处理。"
)
return json.dumps(
{"chunks": [{"output_type": "markdown", "content": summary}]},
ensure_ascii=False,
)
return dispatch_parallel_tasks