603 lines
23 KiB
Python
603 lines
23 KiB
Python
"""Subagent result post-processing: summary budget/spill, tool-trace summaries, lifecycle hooks and cost rollup.
|
|
|
|
Split out of ``tools/delegate_tool.py``; every moved name is re-imported there, so
|
|
``tools.delegate_tool.<name>`` keeps resolving (and monkeypatching) as before.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import json
|
|
import threading
|
|
from typing import Any, Dict, List, Optional
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
# Log-record parity with the origin module.
|
|
logger = logging.getLogger("tools.delegate_tool")
|
|
|
|
|
|
def _extract_output_tail(
|
|
result: Dict[str, Any],
|
|
*,
|
|
max_entries: int = 12,
|
|
max_chars: int = 8000,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Pull the last N tool-call results from a child's conversation.
|
|
|
|
Powers the overlay's "Output" section — the cc-swarm-parity feature.
|
|
We reuse the same messages list the trajectory saver walks, taking
|
|
only the tail to keep event payloads small. Each entry is
|
|
``{tool, preview, is_error}``.
|
|
"""
|
|
messages = result.get("messages") if isinstance(result, dict) else None
|
|
if not isinstance(messages, list):
|
|
return []
|
|
|
|
# Walk in reverse to build a tail; stop when we have enough.
|
|
tail: List[Dict[str, Any]] = []
|
|
pending_call_by_id: Dict[str, str] = {}
|
|
|
|
# First pass (forward): build tool_call_id -> tool_name map
|
|
for msg in messages:
|
|
if not isinstance(msg, dict):
|
|
continue
|
|
if msg.get("role") == "assistant":
|
|
for tc in msg.get("tool_calls") or []:
|
|
tc_id = tc.get("id")
|
|
fn = tc.get("function") or {}
|
|
if tc_id:
|
|
pending_call_by_id[tc_id] = str(fn.get("name") or "tool")
|
|
|
|
# Second pass (reverse): pick tool results, newest first
|
|
for msg in reversed(messages):
|
|
if len(tail) >= max_entries:
|
|
break
|
|
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
|
continue
|
|
# Flatten content-block lists/dicts to text so the overlay shows real
|
|
# output (not a "[{'type': 'text'...}]" blob) and error detection can
|
|
# see markers buried inside content blocks. Crude str() here would
|
|
# mislabel a block-wrapped "Error: ..." result as is_error=False.
|
|
content = _stringify_tool_content(msg.get("content") or "")
|
|
is_error = _looks_like_error_output(content)
|
|
tool_name = pending_call_by_id.get(msg.get("tool_call_id") or "", "tool")
|
|
# Preserve line structure so the overlay's wrapped scroll region can
|
|
# show real output rather than a whitespace-collapsed blob. We still
|
|
# cap the payload size to keep events bounded.
|
|
preview = content[:max_chars]
|
|
tail.append({"tool": tool_name, "preview": preview, "is_error": is_error})
|
|
|
|
tail.reverse() # restore chronological order for display
|
|
return tail
|
|
|
|
|
|
def _stringify_tool_content(content: Any) -> str:
|
|
"""Return a stable text representation for tool-result content.
|
|
|
|
Most providers store tool results as strings, but some OpenAI-compatible
|
|
paths can return content-block lists. Delegate observability must never
|
|
crash while summarising a child run just because the transport used blocks.
|
|
"""
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for item in content:
|
|
if isinstance(item, dict):
|
|
text = item.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
else:
|
|
parts.append(json.dumps(item, ensure_ascii=False, default=str))
|
|
else:
|
|
parts.append(str(item))
|
|
return "\n".join(parts)
|
|
if isinstance(content, dict):
|
|
return json.dumps(content, ensure_ascii=False, default=str)
|
|
return str(content)
|
|
|
|
|
|
_TOOL_INPUT_TARGET_KEYS = frozenset({
|
|
"cwd",
|
|
"destination_path",
|
|
"directory",
|
|
"dst",
|
|
"endpoint",
|
|
"file_path",
|
|
"new_path",
|
|
"old_path",
|
|
"path",
|
|
"source_path",
|
|
"src",
|
|
"target_path",
|
|
"url",
|
|
"urls",
|
|
})
|
|
|
|
_TOOL_INPUT_URL_KEYS = frozenset({"endpoint", "url", "urls"})
|
|
|
|
|
|
def _sanitize_tool_target(key: str, value: Any) -> Any:
|
|
"""Keep bounded side-effect targets while dropping URL secrets."""
|
|
if isinstance(value, list):
|
|
cleaned = [
|
|
item for item in (_sanitize_tool_target(key, item) for item in value[:16])
|
|
if item is not None
|
|
]
|
|
return cleaned or None
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
bounded = value[:1024]
|
|
if key in _TOOL_INPUT_URL_KEYS:
|
|
try:
|
|
parsed = urlsplit(bounded)
|
|
if parsed.scheme and parsed.netloc:
|
|
hostname = parsed.hostname
|
|
if not hostname:
|
|
return None
|
|
# ``SplitResult.netloc`` includes ``user:password@``. Rebuild
|
|
# the authority from parsed host/port so hook-visible history
|
|
# cannot carry URL credentials. Bracket IPv6 literals before
|
|
# appending a validated port.
|
|
host = f"[{hostname}]" if ":" in hostname else hostname
|
|
port = parsed.port
|
|
netloc = f"{host}:{port}" if port is not None else host
|
|
return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
|
|
except ValueError:
|
|
return None
|
|
return bounded
|
|
|
|
|
|
def _empty_input_summary() -> Dict[str, Any]:
|
|
return {"argument_keys": [], "targets": {}}
|
|
|
|
|
|
def _sanitize_targets(mapping: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Keep only known side-effect target keys, each sanitized (URL secrets dropped)."""
|
|
targets: Dict[str, Any] = {}
|
|
for raw_key, value in mapping.items():
|
|
key = str(raw_key).lower()
|
|
if key in _TOOL_INPUT_TARGET_KEYS:
|
|
cleaned = _sanitize_tool_target(key, value)
|
|
if cleaned is not None:
|
|
targets[key] = cleaned
|
|
return targets
|
|
|
|
|
|
def _summarize_tool_arguments(arguments: Any) -> Dict[str, Any]:
|
|
"""Summarize argument names and side-effect targets without raw payloads."""
|
|
if not isinstance(arguments, str):
|
|
return _empty_input_summary()
|
|
try:
|
|
parsed = json.loads(arguments)
|
|
except (TypeError, ValueError):
|
|
return _empty_input_summary()
|
|
if not isinstance(parsed, dict):
|
|
return _empty_input_summary()
|
|
keys = sorted(str(key)[:128] for key in parsed)[:64]
|
|
return {"argument_keys": keys, "targets": _sanitize_targets(parsed)}
|
|
|
|
|
|
def _sanitize_tool_input_summary(summary: Any) -> Dict[str, Any]:
|
|
"""Re-sanitize a stored input summary before handing it to lifecycle hooks."""
|
|
if not isinstance(summary, dict):
|
|
return _empty_input_summary()
|
|
keys = summary.get("argument_keys")
|
|
safe_keys = [str(key)[:128] for key in keys[:64]] if isinstance(keys, list) else []
|
|
targets = summary.get("targets")
|
|
safe_targets = _sanitize_targets(targets) if isinstance(targets, dict) else {}
|
|
return {"argument_keys": safe_keys, "targets": safe_targets}
|
|
|
|
|
|
def _subagent_stop_tool_call_history(tool_trace: Any) -> List[Dict[str, Any]]:
|
|
"""Build a detached, metadata-only tool history for lifecycle hooks."""
|
|
if not isinstance(tool_trace, list):
|
|
return []
|
|
|
|
history: List[Dict[str, Any]] = []
|
|
for item in tool_trace:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
tool_name = str(item.get("tool") or "unknown")[:256]
|
|
status = str(item.get("status") or "unknown").lower()
|
|
if status not in {"ok", "error"}:
|
|
status = "unknown"
|
|
|
|
def _byte_count(key: str) -> int:
|
|
value = item.get(key, 0)
|
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
return 0
|
|
return max(0, int(value))
|
|
|
|
history.append({
|
|
"tool_name": tool_name,
|
|
"tool_input": _sanitize_tool_input_summary(item.get("input_summary")),
|
|
"input_bytes": _byte_count("args_bytes"),
|
|
"output_bytes": _byte_count("result_bytes"),
|
|
"status": status,
|
|
})
|
|
return history
|
|
|
|
|
|
def _looks_like_error_output(content: Any) -> bool:
|
|
"""Conservative stderr/error detector for tool-result previews.
|
|
|
|
The old heuristic flagged any preview containing the substring "error",
|
|
which painted perfectly normal terminal/json output red. We now only
|
|
mark output as an error when there is stronger evidence:
|
|
- structured JSON with an ``error`` key
|
|
- structured JSON with ``status`` of error/failed
|
|
- first line starts with a classic error marker
|
|
"""
|
|
content = _stringify_tool_content(content)
|
|
if not content:
|
|
return False
|
|
|
|
head = content.lstrip()
|
|
if head.startswith("{") or head.startswith("["):
|
|
try:
|
|
parsed = json.loads(content)
|
|
if isinstance(parsed, dict):
|
|
if parsed.get("error"):
|
|
return True
|
|
status = str(parsed.get("status") or "").strip().lower()
|
|
if status in {"error", "failed", "failure", "timeout"}:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
first = content.splitlines()[0].strip().lower() if content.splitlines() else ""
|
|
return (
|
|
first.startswith("error:")
|
|
or first.startswith("failed:")
|
|
or first.startswith("traceback ")
|
|
or first.startswith("exception:")
|
|
)
|
|
|
|
|
|
# Hard per-summary character ceiling layered on top of the dynamic
|
|
# headroom budget (see _apply_summary_budget). Belt-and-suspenders for
|
|
# models that ignore the "be concise" instruction. 0 disables the ceiling.
|
|
DEFAULT_MAX_SUMMARY_CHARS = 24000
|
|
|
|
# Fraction of the parent's *remaining* context headroom that the whole batch
|
|
# of subagent summaries is allowed to consume. The per-summary budget is this
|
|
# slice divided across the batch, so N children can't collectively blow the
|
|
# parent's window (the compression/429 death-spiral in issue/PR #9126).
|
|
_SUMMARY_HEADROOM_FRACTION = 0.5
|
|
|
|
# Floor so a single summary always gets a usable slice even when the parent is
|
|
# already nearly full — below this we'd be truncating to noise.
|
|
_MIN_SUMMARY_CHARS = 2000
|
|
|
|
|
|
def _spill_summary_to_file(task_index: int, summary: str) -> Optional[str]:
|
|
"""Write a subagent's full summary to the delegation cache and return path.
|
|
|
|
Mirrors web_extract's ``_store_full_text``: the file lands in
|
|
``cache/delegation`` which is mounted read-only into remote backends
|
|
(Docker/Modal/SSH) via ``credential_files._CACHE_DIRS``, so the parent's
|
|
terminal/``read_file`` tools can page through the complete text on any
|
|
backend. Returns the absolute path, or None on failure (best-effort:
|
|
the trimmed head+tail is still returned to the parent regardless).
|
|
"""
|
|
try:
|
|
from hermes_constants import get_hermes_dir
|
|
import datetime as _dt
|
|
|
|
cache_dir = get_hermes_dir("cache/delegation", "delegation_cache")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
path = cache_dir / f"subagent-summary-{task_index}-{ts}.txt"
|
|
from tools.spill_safety import write_text_exclusive
|
|
|
|
# Exclusive symlink-refusing create; not private because
|
|
# cache/delegation is bind-mounted read-only into remote backends
|
|
# whose container UID must be able to read it.
|
|
write_text_exclusive(path, summary, private=False)
|
|
return str(path)
|
|
except Exception as exc:
|
|
logger.debug("Failed to spill subagent summary to file: %s", exc)
|
|
return None
|
|
|
|
|
|
def _trim_summary_with_footer(
|
|
summary: str, cap: int, task_index: int
|
|
) -> tuple[str, Optional[str]]:
|
|
"""Return (model_text, spill_path) for one over-budget summary.
|
|
|
|
Mirrors web_extract's ``_truncate_with_footer``: keep a head+tail window
|
|
(~75% head / ~25% tail, snapped to line boundaries) so the subagent's
|
|
opening AND its closing (outcomes / files-changed / issues, which live at
|
|
the end) both survive, spill the full text to disk, and append a footer
|
|
telling the parent exactly how much it's seeing and the precise
|
|
``read_file offset=`` to page into the omitted middle. Deterministic.
|
|
"""
|
|
original_len = len(summary)
|
|
head_budget = int(cap * 0.75)
|
|
tail_budget = cap - head_budget
|
|
|
|
head = summary[:head_budget]
|
|
tail = summary[-tail_budget:]
|
|
# Snap the head cut back to the last newline so we don't slice mid-line.
|
|
nl = head.rfind("\n")
|
|
if nl > head_budget * 0.5:
|
|
head = head[:nl]
|
|
# Snap the tail cut forward to the next newline for the same reason.
|
|
nl = tail.find("\n")
|
|
if 0 <= nl < tail_budget * 0.5:
|
|
tail = tail[nl + 1:]
|
|
|
|
spill_path = _spill_summary_to_file(task_index, summary)
|
|
|
|
footer_lines = [
|
|
"",
|
|
"─" * 8 + " [SUMMARY TRUNCATED] " + "─" * 8,
|
|
f"Showing {len(head):,} chars (head) + {len(tail):,} chars (tail) "
|
|
f"of {original_len:,} total — trimmed to protect the parent's context window.",
|
|
]
|
|
if spill_path:
|
|
# read_file is 1-indexed; +2 moves past the last head line shown.
|
|
middle_start_line = head.count("\n") + 2
|
|
footer_lines.append(f"Full subagent output saved to: {spill_path}")
|
|
footer_lines.append(
|
|
f'To read the omitted middle: read_file path="{spill_path}" '
|
|
f"offset={middle_start_line} limit=200 (the file is the complete "
|
|
f"summary; raise/lower offset to page through it)."
|
|
)
|
|
else:
|
|
footer_lines.append(
|
|
"Full output could not be stored to disk; the head+tail above is "
|
|
"all that was preserved."
|
|
)
|
|
footer_lines.append("─" * 37)
|
|
|
|
model_text = head + "\n\n[... middle omitted — see footer ...]\n\n" + tail + "\n".join(footer_lines)
|
|
return model_text, spill_path
|
|
|
|
|
|
def _parent_summary_char_budget(parent_agent, n_summaries: int) -> Optional[int]:
|
|
"""Per-summary character budget sized against the parent's *remaining*
|
|
context headroom, split across the batch.
|
|
|
|
The overflow this guards against is N summaries entering the parent
|
|
context at once (batch fan-out), not any single summary being large. We
|
|
take a fraction of the headroom the parent has left (resolved context
|
|
length minus what's already in its prompt) and divide it across the batch,
|
|
converting tokens→chars at the standard ~4 chars/token estimate.
|
|
|
|
Returns the per-summary char budget, or None when the parent's context
|
|
state is unknown (no compressor / no token count) — in which case the
|
|
caller falls back to the static char ceiling only.
|
|
"""
|
|
try:
|
|
compressor = getattr(parent_agent, "context_compressor", None)
|
|
context_length = getattr(compressor, "context_length", None)
|
|
if not isinstance(context_length, int) or context_length <= 0:
|
|
return None
|
|
|
|
used_tokens = getattr(parent_agent, "session_prompt_tokens", 0)
|
|
if not isinstance(used_tokens, (int, float)) or used_tokens < 0:
|
|
used_tokens = 0
|
|
|
|
# Reserve the compressor's output budget so we measure INPUT headroom.
|
|
reserved = getattr(compressor, "max_tokens", 0) or 0
|
|
headroom_tokens = context_length - int(used_tokens) - int(reserved)
|
|
if headroom_tokens <= 0:
|
|
# Parent is already over budget — give each summary only the floor.
|
|
return _MIN_SUMMARY_CHARS
|
|
|
|
batch_token_budget = int(headroom_tokens * _SUMMARY_HEADROOM_FRACTION)
|
|
per_summary_tokens = batch_token_budget // max(1, n_summaries)
|
|
per_summary_chars = per_summary_tokens * 4 # ~4 chars/token
|
|
return max(_MIN_SUMMARY_CHARS, per_summary_chars)
|
|
except Exception:
|
|
logger.debug("Summary budget computation failed", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _apply_summary_budget(results: List[Dict[str, Any]], parent_agent) -> None:
|
|
"""Trim subagent summaries in-place so the batch can't overflow the
|
|
parent's context window, spilling full text to disk so nothing is lost.
|
|
|
|
The effective per-summary cap is the MIN of:
|
|
- the dynamic headroom budget (remaining parent context ÷ batch size), and
|
|
- the static ``delegation.max_summary_chars`` ceiling (0 = disabled).
|
|
|
|
When a summary exceeds the cap, its full text is written to a file and the
|
|
in-context summary becomes a head slice plus a pointer to that file. This
|
|
addresses issue/PR #9126: batch fan-out returned N full summaries verbatim,
|
|
blowing the parent context and (on rate-limited providers) triggering a
|
|
compression/429 death spiral.
|
|
"""
|
|
from tools.delegate_tool import _load_config
|
|
summaries = [
|
|
r for r in results if isinstance(r, dict) and isinstance(r.get("summary"), str) and r["summary"]
|
|
]
|
|
if not summaries:
|
|
return
|
|
|
|
cfg = _load_config()
|
|
try:
|
|
static_ceiling = int(cfg.get("max_summary_chars", DEFAULT_MAX_SUMMARY_CHARS))
|
|
except (TypeError, ValueError):
|
|
static_ceiling = DEFAULT_MAX_SUMMARY_CHARS
|
|
|
|
dynamic_budget = _parent_summary_char_budget(parent_agent, len(summaries))
|
|
|
|
# Combine the two caps. Either can be absent/disabled.
|
|
candidates = [c for c in (static_ceiling, dynamic_budget) if c and c > 0]
|
|
if not candidates:
|
|
return # both disabled / unknown → leave summaries untouched
|
|
cap = min(candidates)
|
|
|
|
for entry in summaries:
|
|
summary = entry["summary"]
|
|
if len(summary) <= cap:
|
|
continue
|
|
original_len = len(summary)
|
|
model_text, spill_path = _trim_summary_with_footer(
|
|
summary, cap, entry.get("task_index", -1)
|
|
)
|
|
entry["summary"] = model_text
|
|
entry["summary_truncated"] = True
|
|
if spill_path:
|
|
entry["summary_full_path"] = spill_path
|
|
logger.debug(
|
|
"[subagent-%s] summary trimmed %d → ~%d chars (spill=%s)",
|
|
entry.get("task_index", "?"),
|
|
original_len,
|
|
cap,
|
|
spill_path or "none",
|
|
)
|
|
|
|
|
|
_PARENT_FINALIZATION_LOCK_GUARD = threading.Lock()
|
|
|
|
_PARENT_FINALIZATION_FALLBACK_LOCK = threading.RLock()
|
|
|
|
_CHILD_CONSTRUCTION_LOCK = threading.RLock()
|
|
|
|
|
|
def _build_child_preserving_parent_tools(**kwargs):
|
|
"""Build a child without leaking its resolved toolset into the parent."""
|
|
from tools.delegate_tool import _build_child_agent
|
|
import model_tools
|
|
|
|
with _CHILD_CONSTRUCTION_LOCK:
|
|
parent_tool_names = list(model_tools._last_resolved_tool_names)
|
|
try:
|
|
child = _build_child_agent(**kwargs)
|
|
finally:
|
|
model_tools._last_resolved_tool_names = parent_tool_names
|
|
child._delegate_saved_tool_names = parent_tool_names
|
|
return child
|
|
|
|
|
|
def _parent_finalization_lock(parent_agent) -> threading.RLock:
|
|
"""Return the per-parent lock that serializes lifecycle side effects."""
|
|
if parent_agent is None:
|
|
return _PARENT_FINALIZATION_FALLBACK_LOCK
|
|
lock = getattr(parent_agent, "_subagent_finalization_lock", None)
|
|
if lock is not None:
|
|
return lock
|
|
with _PARENT_FINALIZATION_LOCK_GUARD:
|
|
lock = getattr(parent_agent, "_subagent_finalization_lock", None)
|
|
if lock is None:
|
|
lock = threading.RLock()
|
|
try:
|
|
setattr(parent_agent, "_subagent_finalization_lock", lock)
|
|
except Exception:
|
|
return _PARENT_FINALIZATION_FALLBACK_LOCK
|
|
return lock
|
|
|
|
|
|
def _finalize_child_results(
|
|
results: List[Dict[str, Any]],
|
|
task_list: List[Dict[str, Any]],
|
|
children: List[tuple[int, Dict[str, Any], Any]],
|
|
parent_agent,
|
|
) -> None:
|
|
"""Apply host-owned summary, memory, hook, and cost contracts once."""
|
|
with _parent_finalization_lock(parent_agent):
|
|
_apply_summary_budget(results, parent_agent)
|
|
child_by_index = {index: child for index, _task, child in children}
|
|
|
|
if parent_agent and getattr(parent_agent, "_memory_manager", None):
|
|
for entry in results:
|
|
try:
|
|
task_index = entry.get("task_index", -1)
|
|
task_goal = (
|
|
task_list[task_index]["goal"]
|
|
if isinstance(task_index, int)
|
|
and 0 <= task_index < len(task_list)
|
|
else ""
|
|
)
|
|
child = child_by_index.get(task_index)
|
|
parent_agent._memory_manager.on_delegation(
|
|
task=task_goal,
|
|
result=entry.get("summary", "") or "",
|
|
child_session_id=getattr(child, "session_id", ""),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
parent_session_id = getattr(parent_agent, "session_id", None)
|
|
try:
|
|
from hermes_cli.plugins import invoke_hook as invoke_hook
|
|
except Exception:
|
|
invoke_hook = None
|
|
|
|
children_cost_total = 0.0
|
|
for entry in results:
|
|
child_role = entry.pop("_child_role", None)
|
|
child_cost = entry.pop("_child_cost_usd", 0.0)
|
|
try:
|
|
if child_cost:
|
|
children_cost_total += float(child_cost)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if invoke_hook is None:
|
|
continue
|
|
try:
|
|
child_index = entry.get("task_index", -1)
|
|
child = child_by_index.get(child_index)
|
|
invoke_hook(
|
|
"subagent_stop",
|
|
parent_session_id=parent_session_id,
|
|
parent_turn_id=getattr(parent_agent, "_current_turn_id", "") or "",
|
|
child_session_id=getattr(child, "session_id", None),
|
|
child_role=child_role,
|
|
child_summary=entry.get("summary"),
|
|
child_status=entry.get("status"),
|
|
tool_call_history=_subagent_stop_tool_call_history(
|
|
entry.get("tool_trace")
|
|
),
|
|
duration_ms=int((entry.get("duration_seconds") or 0) * 1000),
|
|
)
|
|
except Exception:
|
|
logger.debug("subagent_stop hook invocation failed", exc_info=True)
|
|
|
|
if children_cost_total > 0.0:
|
|
try:
|
|
current = float(
|
|
getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0
|
|
)
|
|
parent_agent.session_estimated_cost_usd = current + children_cost_total
|
|
if getattr(parent_agent, "session_cost_source", "none") in {
|
|
None,
|
|
"",
|
|
"none",
|
|
}:
|
|
parent_agent.session_cost_source = "subagent"
|
|
if getattr(parent_agent, "session_cost_status", "unknown") in {
|
|
None,
|
|
"",
|
|
"unknown",
|
|
}:
|
|
parent_agent.session_cost_status = "estimated"
|
|
except Exception:
|
|
logger.debug("Subagent cost rollup failed", exc_info=True)
|
|
|
|
|
|
def _run_child_lifecycle(
|
|
task_index: int,
|
|
goal: str,
|
|
child=None,
|
|
parent_agent=None,
|
|
) -> Dict[str, Any]:
|
|
"""Run one child and apply the same host lifecycle used by delegate_task."""
|
|
from tools.delegate_tool import _run_single_child
|
|
result = _run_single_child(task_index, goal, child, parent_agent)
|
|
result.setdefault("task_index", task_index)
|
|
task = {"goal": goal}
|
|
_finalize_child_results(
|
|
[result],
|
|
[{"goal": ""} for _ in range(task_index)] + [task],
|
|
[(task_index, task, child)],
|
|
parent_agent,
|
|
)
|
|
return result
|