5362 lines
228 KiB
Python
5362 lines
228 KiB
Python
from __future__ import annotations
|
||
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from dataclasses import dataclass, field, replace
|
||
from datetime import datetime, timezone
|
||
import json
|
||
from pathlib import Path
|
||
import threading
|
||
from typing import Any, Callable
|
||
from uuid import uuid4
|
||
|
||
from .account_runtime import AccountRuntime
|
||
from .agent_manager import AgentManager
|
||
from .agent_context import clear_context_caches
|
||
from .agent_context import render_context_report as render_agent_context_report
|
||
from .agent_context_usage import collect_context_usage, estimate_tokens, format_context_usage
|
||
from .compact import compact_conversation
|
||
from .ask_user_runtime import AskUserRuntime
|
||
from .agent_registry import (
|
||
delete_agent_definition,
|
||
find_agent_definition,
|
||
normalize_mutable_source,
|
||
load_agent_registry,
|
||
render_agent_mutation,
|
||
render_agent_detail,
|
||
render_agents_report,
|
||
scaffold_agent_definition,
|
||
update_agent_definition,
|
||
)
|
||
from .config_runtime import ConfigRuntime
|
||
from .hook_policy import HookPolicyRuntime
|
||
from .lsp_runtime import LSPRuntime
|
||
from .mcp_runtime import MCPRuntime
|
||
from .agent_prompting import (
|
||
build_prompt_context,
|
||
build_system_prompt_parts,
|
||
render_system_prompt,
|
||
)
|
||
from .agent_session import AgentSessionState
|
||
from .agent_slash_commands import preprocess_slash_command
|
||
from .agent_tools import (
|
||
AgentTool,
|
||
build_tool_context,
|
||
default_tool_registry,
|
||
execute_tool_streaming,
|
||
serialize_tool_result,
|
||
)
|
||
from .agent_types import (
|
||
AgentRunResult,
|
||
AgentPermissions,
|
||
AgentRuntimeConfig,
|
||
AssistantTurn,
|
||
BudgetConfig,
|
||
ModelConfig,
|
||
OutputSchemaConfig,
|
||
StreamEvent,
|
||
ToolCall,
|
||
ToolExecutionResult,
|
||
UsageStats,
|
||
)
|
||
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
||
from .plan_runtime import PlanRuntime
|
||
from .plugin_runtime import PluginRuntime
|
||
from .remote_runtime import RemoteRuntime
|
||
from .remote_trigger_runtime import RemoteTriggerRuntime
|
||
from .search_runtime import SearchRuntime
|
||
from .task_runtime import TaskRuntime
|
||
from .team_runtime import TeamRuntime
|
||
from .tokenizer_runtime import describe_token_counter
|
||
from .workflow_runtime import WorkflowRuntime
|
||
from .worktree_runtime import WorktreeRuntime
|
||
from .session_env_vars import clear_session_env_vars
|
||
|
||
RuntimeEventSink = Callable[[dict[str, object]], None]
|
||
|
||
# 防止模型在截断续写时无限自我延长,保留少量自动续写空间即可。
|
||
MAX_AUTO_CONTINUATIONS = 3
|
||
CONTINUATION_FINISH_REASONS = {'length', 'max_tokens'}
|
||
|
||
|
||
class _RuntimeEventBuffer(list[dict[str, object]]):
|
||
def __init__(self, event_sink: RuntimeEventSink | None = None) -> None:
|
||
super().__init__()
|
||
self._event_sink = event_sink
|
||
|
||
def append(self, event: dict[str, object]) -> None: # type: ignore[override]
|
||
super().append(event)
|
||
if self._event_sink is not None:
|
||
self._event_sink(dict(event))
|
||
|
||
def extend(self, events: Any) -> None: # type: ignore[override]
|
||
for event in events:
|
||
self.append(event)
|
||
|
||
|
||
def _append_final_text_stream_events(
|
||
stream_events: list[dict[str, object]],
|
||
text: str,
|
||
) -> None:
|
||
if not text:
|
||
return
|
||
stream_events.append({'type': 'final_text_start'})
|
||
chunk_size = 96
|
||
for start in range(0, len(text), chunk_size):
|
||
stream_events.append(
|
||
{
|
||
'type': 'final_text_delta',
|
||
'delta': text[start : start + chunk_size],
|
||
}
|
||
)
|
||
stream_events.append({'type': 'final_text_end'})
|
||
|
||
|
||
from .session_store import (
|
||
StoredAgentSession,
|
||
load_agent_session,
|
||
save_agent_session,
|
||
serialize_model_config,
|
||
serialize_runtime_config,
|
||
usage_from_payload,
|
||
)
|
||
from .token_budget import calculate_token_budget, format_token_budget
|
||
from .builtin_agents import (
|
||
AgentDefinition,
|
||
ALL_AGENT_DISALLOWED_TOOLS,
|
||
GENERAL_PURPOSE_AGENT,
|
||
)
|
||
from .microcompact import microcompact_messages as _microcompact_messages
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BudgetDecision:
|
||
exceeded: bool
|
||
reason: str | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PromptPreflightResult:
|
||
usage_increment: UsageStats = field(default_factory=UsageStats)
|
||
model_calls_increment: int = 0
|
||
stop_reason: str | None = None
|
||
reason: str | None = None
|
||
|
||
|
||
def _prepend_runtime_context(prompt: str, runtime_context: str | None) -> str:
|
||
"""把仅模型可见的运行上下文附加到本轮 prompt 前,不污染用户历史消息。"""
|
||
if not runtime_context or not runtime_context.strip():
|
||
return prompt
|
||
return '\n\n'.join(
|
||
[
|
||
'<system-reminder>',
|
||
runtime_context.strip(),
|
||
'</system-reminder>',
|
||
prompt,
|
||
]
|
||
)
|
||
|
||
|
||
def _log_child_skill_calls(
|
||
child_result: 'AgentRunResult',
|
||
child_agent: 'LocalCodingAgent',
|
||
subtask_label: str,
|
||
) -> None:
|
||
"""Extract Skill tool calls from a child agent's transcript and write to a log file.
|
||
|
||
Logs each Skill invocation with the args sent and the agent's response,
|
||
so we can audit whether sub-agents actually called skills like label-master.
|
||
"""
|
||
from .agent_types import AgentRunResult # noqa: F811
|
||
|
||
transcript = child_result.transcript
|
||
if not transcript:
|
||
return
|
||
log_dir = child_agent.runtime_config.session_directory / '_subagent_logs'
|
||
try:
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
except OSError:
|
||
return
|
||
session_id = child_result.session_id or 'unknown'
|
||
log_path = log_dir / f'{session_id}.jsonl'
|
||
|
||
skill_calls: list[dict[str, object]] = []
|
||
# Build a map of tool_call_id -> skill args from assistant messages with tool_calls
|
||
pending_skills: dict[str, dict[str, str]] = {}
|
||
for entry in transcript:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
role = entry.get('role')
|
||
if role == 'assistant':
|
||
tool_calls = entry.get('tool_calls')
|
||
if isinstance(tool_calls, list):
|
||
for tc in tool_calls:
|
||
if not isinstance(tc, dict):
|
||
continue
|
||
fn = tc.get('function') or {}
|
||
if not isinstance(fn, dict):
|
||
continue
|
||
if fn.get('name') == 'Skill':
|
||
tc_id = tc.get('id', '')
|
||
raw_args = fn.get('arguments', '{}')
|
||
try:
|
||
args_dict = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||
except (json.JSONDecodeError, TypeError):
|
||
args_dict = {'raw': raw_args}
|
||
pending_skills[tc_id] = {
|
||
'skill': args_dict.get('skill', ''),
|
||
'args': args_dict.get('args', ''),
|
||
}
|
||
elif role == 'tool':
|
||
tc_id = entry.get('tool_call_id', '')
|
||
if tc_id in pending_skills:
|
||
skill_info = pending_skills.pop(tc_id)
|
||
skill_calls.append({
|
||
'skill': skill_info['skill'],
|
||
'args': skill_info['args'],
|
||
'prompt_injected': entry.get('content') or '',
|
||
'ts': datetime.now(timezone.utc).isoformat(),
|
||
})
|
||
|
||
# Also capture the assistant responses that follow skill injections
|
||
# by looking at assistant messages after tool messages
|
||
assistant_after_skill: list[str] = []
|
||
saw_skill_tool = False
|
||
for entry in transcript:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
role = entry.get('role')
|
||
if role == 'tool' and entry.get('tool_call_id', '') in [
|
||
tc.get('id', '') for tc in _all_skill_tool_call_ids(transcript)
|
||
]:
|
||
saw_skill_tool = True
|
||
elif role == 'assistant' and saw_skill_tool:
|
||
content = entry.get('content', '')
|
||
if content:
|
||
assistant_after_skill.append(content)
|
||
saw_skill_tool = False
|
||
|
||
# Merge assistant responses into skill_calls
|
||
for i, response_text in enumerate(assistant_after_skill):
|
||
if i < len(skill_calls):
|
||
skill_calls[i]['agent_response'] = response_text
|
||
|
||
if not skill_calls:
|
||
return
|
||
try:
|
||
with open(log_path, 'a', encoding='utf-8') as f:
|
||
for call in skill_calls:
|
||
call['subtask_label'] = subtask_label
|
||
f.write(json.dumps(call, ensure_ascii=False) + '\n')
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _all_skill_tool_call_ids(transcript: tuple[dict[str, object], ...]) -> list[dict[str, str]]:
|
||
"""Collect all tool_call entries for Skill from assistant messages."""
|
||
results = []
|
||
for entry in transcript:
|
||
if not isinstance(entry, dict) or entry.get('role') != 'assistant':
|
||
continue
|
||
tool_calls = entry.get('tool_calls')
|
||
if not isinstance(tool_calls, list):
|
||
continue
|
||
for tc in tool_calls:
|
||
if not isinstance(tc, dict):
|
||
continue
|
||
fn = tc.get('function') or {}
|
||
if isinstance(fn, dict) and fn.get('name') == 'Skill':
|
||
results.append(tc)
|
||
return results
|
||
|
||
|
||
@dataclass
|
||
class LocalCodingAgent:
|
||
model_config: ModelConfig
|
||
runtime_config: AgentRuntimeConfig
|
||
custom_system_prompt: str | None = None
|
||
append_system_prompt: str | None = None
|
||
override_system_prompt: str | None = None
|
||
tool_registry: dict[str, AgentTool] | None = None
|
||
agent_manager: AgentManager | None = None
|
||
parent_agent_id: str | None = None
|
||
managed_group_id: str | None = None
|
||
managed_child_index: int | None = None
|
||
managed_label: str | None = None
|
||
session_metadata: dict[str, object] = field(default_factory=dict)
|
||
plugin_runtime: PluginRuntime | None = None
|
||
hook_policy_runtime: HookPolicyRuntime | None = None
|
||
mcp_runtime: MCPRuntime | None = None
|
||
remote_runtime: RemoteRuntime | None = None
|
||
remote_trigger_runtime: RemoteTriggerRuntime | None = None
|
||
search_runtime: SearchRuntime | None = None
|
||
account_runtime: AccountRuntime | None = None
|
||
ask_user_runtime: AskUserRuntime | None = None
|
||
config_runtime: ConfigRuntime | None = None
|
||
lsp_runtime: LSPRuntime | None = None
|
||
plan_runtime: PlanRuntime | None = None
|
||
task_runtime: TaskRuntime | None = None
|
||
team_runtime: TeamRuntime | None = None
|
||
workflow_runtime: WorkflowRuntime | None = None
|
||
worktree_runtime: WorktreeRuntime | None = None
|
||
last_session: AgentSessionState | None = field(default=None, init=False, repr=False)
|
||
last_run_result: AgentRunResult | None = field(default=None, init=False, repr=False)
|
||
cumulative_usage: UsageStats = field(default_factory=UsageStats, init=False, repr=False)
|
||
cumulative_cost_usd: float = field(default=0.0, init=False, repr=False)
|
||
_compact_consecutive_failures: int = field(default=0, init=False, repr=False)
|
||
active_session_id: str | None = field(default=None, init=False, repr=False)
|
||
last_session_path: str | None = field(default=None, init=False, repr=False)
|
||
managed_agent_id: str | None = field(default=None, init=False, repr=False)
|
||
resume_source_session_id: str | None = field(default=None, init=False, repr=False)
|
||
|
||
def __post_init__(self) -> None:
|
||
if self.tool_registry is None:
|
||
self.tool_registry = default_tool_registry()
|
||
if self.agent_manager is None:
|
||
self.agent_manager = AgentManager()
|
||
if self.plugin_runtime is None:
|
||
self.plugin_runtime = PluginRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.hook_policy_runtime is None:
|
||
self.hook_policy_runtime = HookPolicyRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.mcp_runtime is None:
|
||
self.mcp_runtime = MCPRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.remote_runtime is None:
|
||
self.remote_runtime = RemoteRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.remote_trigger_runtime is None:
|
||
self.remote_trigger_runtime = RemoteTriggerRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.search_runtime is None:
|
||
self.search_runtime = SearchRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.account_runtime is None:
|
||
self.account_runtime = AccountRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.ask_user_runtime is None:
|
||
self.ask_user_runtime = AskUserRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.config_runtime is None:
|
||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||
if self.lsp_runtime is None:
|
||
self.lsp_runtime = LSPRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.plan_runtime is None:
|
||
self.plan_runtime = PlanRuntime()
|
||
if self.task_runtime is None:
|
||
self.task_runtime = TaskRuntime()
|
||
if self.team_runtime is None:
|
||
self.team_runtime = TeamRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.workflow_runtime is None:
|
||
self.workflow_runtime = WorkflowRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
tuple(str(path) for path in self.runtime_config.additional_working_directories),
|
||
)
|
||
if self.worktree_runtime is None:
|
||
self.worktree_runtime = WorktreeRuntime.from_workspace(self.runtime_config.cwd)
|
||
self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
|
||
registry = dict(self.tool_registry)
|
||
plugin_tools = self.plugin_runtime.register_tool_aliases(registry)
|
||
if plugin_tools:
|
||
registry = {**registry, **plugin_tools}
|
||
virtual_tools = self.plugin_runtime.register_virtual_tools(registry)
|
||
if virtual_tools:
|
||
registry = {**registry, **virtual_tools}
|
||
self.tool_registry = registry
|
||
self.client = OpenAICompatClient(self.model_config)
|
||
self.tool_context = build_tool_context(
|
||
self.runtime_config,
|
||
tool_registry=self.tool_registry,
|
||
extra_env=(
|
||
self.hook_policy_runtime.safe_env()
|
||
if self.hook_policy_runtime is not None
|
||
else None
|
||
),
|
||
search_runtime=self.search_runtime,
|
||
account_runtime=self.account_runtime,
|
||
ask_user_runtime=self.ask_user_runtime,
|
||
config_runtime=self.config_runtime,
|
||
lsp_runtime=self.lsp_runtime,
|
||
mcp_runtime=self.mcp_runtime,
|
||
remote_runtime=self.remote_runtime,
|
||
remote_trigger_runtime=self.remote_trigger_runtime,
|
||
plan_runtime=self.plan_runtime,
|
||
task_runtime=self.task_runtime,
|
||
team_runtime=self.team_runtime,
|
||
workflow_runtime=self.workflow_runtime,
|
||
worktree_runtime=self.worktree_runtime,
|
||
)
|
||
|
||
def set_model(self, model: str) -> None:
|
||
self.model_config = replace(self.model_config, model=model)
|
||
self.client = OpenAICompatClient(self.model_config)
|
||
|
||
def clear_runtime_state(self) -> None:
|
||
self.last_session = None
|
||
self.last_run_result = None
|
||
self.active_session_id = None
|
||
self.last_session_path = None
|
||
self.resume_source_session_id = None
|
||
if self.plugin_runtime is not None:
|
||
self.plugin_runtime.restore_session_state({})
|
||
# Mirror commands/clear/caches.ts: drop session-scoped env vars on /clear.
|
||
clear_session_env_vars()
|
||
|
||
def build_prompt_context(self, scratchpad_directory: Path | None = None):
|
||
return build_prompt_context(
|
||
self.runtime_config,
|
||
self.model_config,
|
||
scratchpad_directory=scratchpad_directory,
|
||
)
|
||
|
||
def build_system_prompt_parts(self, prompt_context=None) -> list[str]:
|
||
if prompt_context is None:
|
||
prompt_context = self.build_prompt_context()
|
||
return build_system_prompt_parts(
|
||
prompt_context=prompt_context,
|
||
runtime_config=self.runtime_config,
|
||
tools=self.tool_registry,
|
||
available_agents=self.available_agents(),
|
||
custom_system_prompt=self.custom_system_prompt,
|
||
append_system_prompt=self.append_system_prompt,
|
||
override_system_prompt=self.override_system_prompt,
|
||
)
|
||
|
||
def load_agent_registry(self):
|
||
return load_agent_registry(self.runtime_config.cwd)
|
||
|
||
def available_agents(self) -> tuple[AgentDefinition, ...]:
|
||
return self.load_agent_registry().active_agents
|
||
|
||
def build_session(
|
||
self,
|
||
user_prompt: str | None = None,
|
||
*,
|
||
scratchpad_directory: Path | None = None,
|
||
) -> AgentSessionState:
|
||
prompt_context = self.build_prompt_context(scratchpad_directory)
|
||
system_prompt_parts = self.build_system_prompt_parts(prompt_context)
|
||
return AgentSessionState.create(
|
||
system_prompt_parts,
|
||
user_prompt,
|
||
user_context=prompt_context.user_context,
|
||
system_context=prompt_context.system_context,
|
||
)
|
||
|
||
def _apply_hook_policy_budget_overrides(
|
||
self,
|
||
runtime_config: AgentRuntimeConfig,
|
||
) -> AgentRuntimeConfig:
|
||
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
|
||
return runtime_config
|
||
overrides = self.hook_policy_runtime.budget_overrides()
|
||
if not overrides:
|
||
return runtime_config
|
||
budget = runtime_config.budget_config
|
||
return replace(
|
||
runtime_config,
|
||
budget_config=BudgetConfig(
|
||
max_total_tokens=(
|
||
budget.max_total_tokens
|
||
if budget.max_total_tokens is not None
|
||
else _optional_policy_int(overrides.get('max_total_tokens'))
|
||
),
|
||
max_input_tokens=(
|
||
budget.max_input_tokens
|
||
if budget.max_input_tokens is not None
|
||
else _optional_policy_int(overrides.get('max_input_tokens'))
|
||
),
|
||
max_output_tokens=(
|
||
budget.max_output_tokens
|
||
if budget.max_output_tokens is not None
|
||
else _optional_policy_int(overrides.get('max_output_tokens'))
|
||
),
|
||
max_reasoning_tokens=(
|
||
budget.max_reasoning_tokens
|
||
if budget.max_reasoning_tokens is not None
|
||
else _optional_policy_int(overrides.get('max_reasoning_tokens'))
|
||
),
|
||
max_total_cost_usd=(
|
||
budget.max_total_cost_usd
|
||
if budget.max_total_cost_usd is not None
|
||
else _optional_policy_float(overrides.get('max_total_cost_usd'))
|
||
),
|
||
max_tool_calls=(
|
||
budget.max_tool_calls
|
||
if budget.max_tool_calls is not None
|
||
else _optional_policy_int(overrides.get('max_tool_calls'))
|
||
),
|
||
max_delegated_tasks=(
|
||
budget.max_delegated_tasks
|
||
if budget.max_delegated_tasks is not None
|
||
else _optional_policy_int(overrides.get('max_delegated_tasks'))
|
||
),
|
||
max_model_calls=(
|
||
budget.max_model_calls
|
||
if budget.max_model_calls is not None
|
||
else _optional_policy_int(overrides.get('max_model_calls'))
|
||
),
|
||
max_session_turns=(
|
||
budget.max_session_turns
|
||
if budget.max_session_turns is not None
|
||
else _optional_policy_int(overrides.get('max_session_turns'))
|
||
),
|
||
),
|
||
)
|
||
|
||
def run(
|
||
self,
|
||
prompt: str,
|
||
session_id: str | None = None,
|
||
*,
|
||
runtime_context: str | None = None,
|
||
event_sink: RuntimeEventSink | None = None,
|
||
) -> AgentRunResult:
|
||
self.managed_agent_id = None
|
||
self.resume_source_session_id = None
|
||
if self.plugin_runtime is not None:
|
||
self.plugin_runtime.restore_session_state({})
|
||
session_id = session_id or uuid4().hex
|
||
scratchpad_directory = self._ensure_scratchpad_directory(session_id)
|
||
self._bind_session_plan_task_runtime(scratchpad_directory)
|
||
result = self._run_prompt(
|
||
prompt,
|
||
base_session=None,
|
||
session_id=session_id,
|
||
scratchpad_directory=scratchpad_directory,
|
||
existing_file_history=(),
|
||
runtime_context=runtime_context,
|
||
event_sink=event_sink,
|
||
)
|
||
self._accumulate_usage(result)
|
||
self._finalize_managed_agent(result)
|
||
return result
|
||
|
||
def resume(
|
||
self,
|
||
prompt: str,
|
||
stored_session: StoredAgentSession,
|
||
*,
|
||
runtime_context: str | None = None,
|
||
event_sink: RuntimeEventSink | None = None,
|
||
) -> AgentRunResult:
|
||
self.managed_agent_id = None
|
||
self.resume_source_session_id = stored_session.session_id
|
||
session = AgentSessionState.from_persisted(
|
||
system_prompt_parts=stored_session.system_prompt_parts,
|
||
user_context=stored_session.user_context,
|
||
system_context=stored_session.system_context,
|
||
messages=stored_session.messages,
|
||
)
|
||
self._append_file_history_replay_if_needed(
|
||
session,
|
||
stored_session.file_history,
|
||
)
|
||
self._append_compaction_replay_if_needed(session)
|
||
self.active_session_id = stored_session.session_id
|
||
self.last_session = session
|
||
self.last_session_path = str(
|
||
self.runtime_config.session_directory
|
||
/ stored_session.session_id
|
||
/ 'session.json'
|
||
)
|
||
if self.plugin_runtime is not None:
|
||
self.plugin_runtime.restore_session_state(stored_session.plugin_state)
|
||
scratchpad_directory = (
|
||
Path(stored_session.scratchpad_directory)
|
||
if stored_session.scratchpad_directory
|
||
else self._ensure_scratchpad_directory(stored_session.session_id)
|
||
)
|
||
self._bind_session_plan_task_runtime(scratchpad_directory)
|
||
result = self._run_prompt(
|
||
prompt,
|
||
base_session=session,
|
||
session_id=stored_session.session_id,
|
||
scratchpad_directory=scratchpad_directory,
|
||
existing_file_history=stored_session.file_history,
|
||
runtime_context=runtime_context,
|
||
event_sink=event_sink,
|
||
)
|
||
self._accumulate_usage(result)
|
||
self._finalize_managed_agent(result)
|
||
return result
|
||
|
||
def _run_prompt(
|
||
self,
|
||
prompt: str,
|
||
*,
|
||
base_session: AgentSessionState | None,
|
||
session_id: str,
|
||
scratchpad_directory: Path | None,
|
||
existing_file_history: tuple[dict[str, object], ...],
|
||
runtime_context: str | None = None,
|
||
event_sink: RuntimeEventSink | None = None,
|
||
) -> AgentRunResult:
|
||
slash_result = preprocess_slash_command(self, prompt)
|
||
if slash_result.handled and not slash_result.should_query:
|
||
return AgentRunResult(
|
||
final_output=slash_result.output,
|
||
turns=0,
|
||
tool_calls=0,
|
||
transcript=slash_result.transcript,
|
||
session_id=self.active_session_id,
|
||
session_path=self.last_session_path,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
|
||
effective_prompt = self._apply_hook_policy_before_prompt_hooks(
|
||
slash_result.prompt or prompt
|
||
)
|
||
effective_prompt = self._apply_plugin_before_prompt_hooks(effective_prompt)
|
||
effective_prompt = self._apply_plugin_resume_hooks(
|
||
effective_prompt,
|
||
resumed=base_session is not None,
|
||
)
|
||
self.managed_agent_id = self.agent_manager.start_agent(
|
||
prompt=effective_prompt,
|
||
parent_agent_id=self.parent_agent_id,
|
||
group_id=self.managed_group_id,
|
||
child_index=self.managed_child_index,
|
||
label=self.managed_label or ('root' if base_session is None else 'resume'),
|
||
resumed_from_session_id=self.resume_source_session_id,
|
||
)
|
||
session = (
|
||
base_session
|
||
if base_session is not None
|
||
else self.build_session(
|
||
None,
|
||
scratchpad_directory=scratchpad_directory,
|
||
)
|
||
)
|
||
model_prompt = _prepend_runtime_context(
|
||
effective_prompt,
|
||
runtime_context,
|
||
)
|
||
session.append_user(effective_prompt, model_content=model_prompt)
|
||
self.last_session = session
|
||
self.active_session_id = session_id
|
||
self.tool_context = replace(
|
||
self.tool_context,
|
||
scratchpad_directory=scratchpad_directory,
|
||
plan_runtime=self.plan_runtime,
|
||
task_runtime=self.task_runtime,
|
||
)
|
||
tool_specs = [tool.to_openai_tool() for tool in self.tool_registry.values()]
|
||
starting_usage = UsageStats()
|
||
starting_cost_usd = 0.0
|
||
starting_tool_calls = 0
|
||
starting_session_turns = 0
|
||
starting_model_calls = 0
|
||
if base_session is not None and self.resume_source_session_id:
|
||
try:
|
||
stored_resume_state = load_agent_session(
|
||
self.resume_source_session_id,
|
||
directory=self.runtime_config.session_directory,
|
||
)
|
||
except OSError:
|
||
stored_resume_state = None
|
||
if stored_resume_state is not None:
|
||
starting_usage = usage_from_payload(stored_resume_state.usage)
|
||
starting_cost_usd = stored_resume_state.total_cost_usd
|
||
starting_tool_calls = stored_resume_state.tool_calls
|
||
starting_session_turns = stored_resume_state.turns
|
||
budget_state = (
|
||
stored_resume_state.budget_state
|
||
if isinstance(stored_resume_state.budget_state, dict)
|
||
else {}
|
||
)
|
||
starting_model_calls = int(budget_state.get('model_calls', 0)) if isinstance(budget_state.get('model_calls', 0), int) else 0
|
||
tool_calls = starting_tool_calls
|
||
last_content = ''
|
||
total_usage = starting_usage
|
||
total_cost_usd = starting_cost_usd
|
||
file_history = list(existing_file_history)
|
||
stream_events: list[dict[str, object]] = _RuntimeEventBuffer(event_sink)
|
||
assistant_response_segments: list[str] = []
|
||
continuation_count = 0
|
||
active_skill_names: set[str] = set()
|
||
duplicate_skill_counts: dict[str, int] = {}
|
||
delegated_tasks = sum(
|
||
1 for entry in file_history if entry.get('action') in ('delegate_agent', 'Agent')
|
||
)
|
||
model_calls = starting_model_calls
|
||
|
||
initial_budget = self._check_budget(
|
||
total_usage,
|
||
total_cost_usd,
|
||
tool_calls=tool_calls,
|
||
delegated_tasks=delegated_tasks,
|
||
model_calls=model_calls,
|
||
session_turns=starting_session_turns,
|
||
)
|
||
if initial_budget.exceeded:
|
||
result = AgentRunResult(
|
||
final_output=initial_budget.reason or 'Stopped before the first model call.',
|
||
turns=0,
|
||
tool_calls=0,
|
||
transcript=session.transcript(),
|
||
session_id=session_id,
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='budget_exceeded',
|
||
file_history=tuple(file_history),
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
for turn_index in range(1, self.runtime_config.max_turns + 1):
|
||
self._microcompact_session_if_needed(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
)
|
||
self._snip_session_if_needed(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
)
|
||
self._compact_session_if_needed(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
)
|
||
preflight = self._preflight_prompt_length(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
)
|
||
if preflight.usage_increment.total_tokens or preflight.model_calls_increment:
|
||
total_usage = total_usage + preflight.usage_increment
|
||
total_cost_usd = self.model_config.pricing.estimate_cost_usd(total_usage)
|
||
model_calls += preflight.model_calls_increment
|
||
budget_after_preflight = self._check_budget(
|
||
total_usage,
|
||
total_cost_usd,
|
||
tool_calls=tool_calls,
|
||
delegated_tasks=delegated_tasks,
|
||
model_calls=model_calls,
|
||
session_turns=starting_session_turns + turn_index,
|
||
)
|
||
if budget_after_preflight.exceeded:
|
||
result = AgentRunResult(
|
||
final_output=(
|
||
budget_after_preflight.reason
|
||
or 'Stopped because the runtime budget was exceeded.'
|
||
),
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='budget_exceeded',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
if preflight.stop_reason is not None:
|
||
result = AgentRunResult(
|
||
final_output=preflight.reason or 'Stopped before the next model call.',
|
||
turns=max(turn_index - 1, 0),
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason=preflight.stop_reason,
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=max(turn_index - 1, 0),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
try:
|
||
turn, turn_events = self._query_model(
|
||
session,
|
||
tool_specs,
|
||
stream_events=stream_events,
|
||
)
|
||
except OpenAICompatError as exc:
|
||
if self._is_prompt_too_long_error(exc) and self._reactive_compact_session(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
):
|
||
try:
|
||
turn, turn_events = self._query_model(
|
||
session,
|
||
tool_specs,
|
||
stream_events=stream_events,
|
||
)
|
||
except OpenAICompatError as retry_exc:
|
||
exc = retry_exc
|
||
else:
|
||
stream_events.extend(
|
||
{
|
||
'type': 'reactive_compact_retry',
|
||
'turn_index': turn_index,
|
||
}
|
||
for _ in [0]
|
||
)
|
||
stream_events.extend(event.to_dict() for event in turn_events)
|
||
model_calls += 1
|
||
total_usage = total_usage + turn.usage
|
||
total_cost_usd = self.model_config.pricing.estimate_cost_usd(total_usage)
|
||
last_content = turn.content
|
||
|
||
budget_after_model = self._check_budget(
|
||
total_usage,
|
||
total_cost_usd,
|
||
tool_calls=tool_calls,
|
||
delegated_tasks=delegated_tasks,
|
||
model_calls=model_calls,
|
||
session_turns=starting_session_turns + turn_index,
|
||
)
|
||
if budget_after_model.exceeded:
|
||
result = AgentRunResult(
|
||
final_output=(
|
||
budget_after_model.reason
|
||
or 'Stopped because the runtime budget was exceeded.'
|
||
),
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='budget_exceeded',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
if not turn.tool_calls:
|
||
assistant_response_segments.append(turn.content)
|
||
if self._is_empty_truncated_response(turn):
|
||
final_output = self._build_empty_truncated_response_output()
|
||
session.append_assistant(
|
||
final_output,
|
||
message_id=f'assistant_{len(session.messages)}',
|
||
stop_reason='empty_truncated_response',
|
||
)
|
||
_append_final_text_stream_events(stream_events, final_output)
|
||
result = AgentRunResult(
|
||
final_output=final_output,
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='empty_truncated_response',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
if self._should_continue_response(
|
||
turn,
|
||
continuation_count=continuation_count,
|
||
):
|
||
continuation_count += 1
|
||
session.append_user(
|
||
self._build_continuation_prompt(),
|
||
metadata={
|
||
'kind': 'continuation_request',
|
||
'continuation_index': continuation_count,
|
||
},
|
||
message_id=f'continuation_{turn_index}',
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'continuation_request',
|
||
'reason': turn.finish_reason,
|
||
'continuation_index': continuation_count,
|
||
}
|
||
)
|
||
last_content = ''.join(assistant_response_segments)
|
||
continue
|
||
final_output = ''.join(assistant_response_segments)
|
||
if self._reached_auto_continuation_limit(
|
||
turn,
|
||
continuation_count=continuation_count,
|
||
):
|
||
stream_events.append(
|
||
{
|
||
'type': 'continuation_limit_reached',
|
||
'reason': turn.finish_reason,
|
||
'max_continuations': MAX_AUTO_CONTINUATIONS,
|
||
}
|
||
)
|
||
final_output = self._append_continuation_limit_note(final_output)
|
||
_append_final_text_stream_events(stream_events, final_output)
|
||
result = AgentRunResult(
|
||
final_output=final_output,
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason=turn.finish_reason,
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
# fall through to the normal tool-call branch below
|
||
# normal error path if not recovered
|
||
final_output = str(exc)
|
||
session.append_assistant(
|
||
final_output,
|
||
message_id=f'assistant_{len(session.messages)}',
|
||
stop_reason='backend_error',
|
||
)
|
||
_append_final_text_stream_events(stream_events, final_output)
|
||
result = AgentRunResult(
|
||
final_output=final_output,
|
||
turns=max(turn_index - 1, 0),
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='backend_error',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=turn_index,
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
stream_events.extend(event.to_dict() for event in turn_events)
|
||
model_calls += 1
|
||
total_usage = total_usage + turn.usage
|
||
total_cost_usd = self.model_config.pricing.estimate_cost_usd(total_usage)
|
||
last_content = turn.content
|
||
|
||
budget_after_model = self._check_budget(
|
||
total_usage,
|
||
total_cost_usd,
|
||
tool_calls=tool_calls,
|
||
delegated_tasks=delegated_tasks,
|
||
model_calls=model_calls,
|
||
session_turns=starting_session_turns + turn_index,
|
||
)
|
||
if budget_after_model.exceeded:
|
||
result = AgentRunResult(
|
||
final_output=(
|
||
budget_after_model.reason
|
||
or 'Stopped because the runtime budget was exceeded.'
|
||
),
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='budget_exceeded',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
if not turn.tool_calls:
|
||
assistant_response_segments.append(turn.content)
|
||
if self._is_empty_truncated_response(turn):
|
||
final_output = self._build_empty_truncated_response_output()
|
||
session.append_assistant(
|
||
final_output,
|
||
message_id=f'assistant_{len(session.messages)}',
|
||
stop_reason='empty_truncated_response',
|
||
)
|
||
_append_final_text_stream_events(stream_events, final_output)
|
||
result = AgentRunResult(
|
||
final_output=final_output,
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='empty_truncated_response',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=turn_index,
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
if self._should_continue_response(
|
||
turn,
|
||
continuation_count=continuation_count,
|
||
):
|
||
continuation_count += 1
|
||
session.append_user(
|
||
self._build_continuation_prompt(),
|
||
metadata={
|
||
'kind': 'continuation_request',
|
||
'continuation_index': continuation_count,
|
||
},
|
||
message_id=f'continuation_{turn_index}',
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'continuation_request',
|
||
'reason': turn.finish_reason,
|
||
'continuation_index': continuation_count,
|
||
}
|
||
)
|
||
last_content = ''.join(assistant_response_segments)
|
||
continue
|
||
final_output = ''.join(assistant_response_segments)
|
||
if self._reached_auto_continuation_limit(
|
||
turn,
|
||
continuation_count=continuation_count,
|
||
):
|
||
stream_events.append(
|
||
{
|
||
'type': 'continuation_limit_reached',
|
||
'reason': turn.finish_reason,
|
||
'max_continuations': MAX_AUTO_CONTINUATIONS,
|
||
}
|
||
)
|
||
final_output = self._append_continuation_limit_note(final_output)
|
||
_append_final_text_stream_events(stream_events, final_output)
|
||
result = AgentRunResult(
|
||
final_output=final_output,
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason=turn.finish_reason,
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=turn_index,
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
for tool_call in turn.tool_calls:
|
||
assistant_response_segments.clear()
|
||
continuation_count = 0
|
||
tool_calls += 1
|
||
if tool_call.name in ('Agent', 'delegate_agent'):
|
||
delegated_tasks += self._delegated_task_units(tool_call.arguments)
|
||
budget_after_tool_request = self._check_budget(
|
||
total_usage,
|
||
total_cost_usd,
|
||
tool_calls=tool_calls,
|
||
delegated_tasks=delegated_tasks,
|
||
model_calls=model_calls,
|
||
session_turns=starting_session_turns + turn_index,
|
||
)
|
||
if budget_after_tool_request.exceeded:
|
||
stream_events.append(
|
||
{
|
||
'type': 'task_budget_exceeded',
|
||
'turn_index': turn_index,
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'reason': budget_after_tool_request.reason,
|
||
}
|
||
)
|
||
result = AgentRunResult(
|
||
final_output=(
|
||
budget_after_tool_request.reason
|
||
or 'Stopped because the runtime budget was exceeded.'
|
||
),
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='budget_exceeded',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
tool_result = None
|
||
tool_message_index = session.start_tool(
|
||
name=tool_call.name,
|
||
tool_call_id=tool_call.id,
|
||
message_id=f'tool_{len(session.messages)}',
|
||
metadata={'phase': 'starting'},
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'tool_start',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'arguments': dict(tool_call.arguments),
|
||
'assistant_content': turn.content,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
}
|
||
)
|
||
if self.plugin_runtime is not None:
|
||
self.plugin_runtime.record_tool_attempt(tool_call.name, blocked=False)
|
||
plugin_preflight_messages = self._plugin_tool_preflight_messages(tool_call.name)
|
||
policy_preflight_messages = self._hook_policy_tool_preflight_messages(
|
||
tool_call.name
|
||
)
|
||
if plugin_preflight_messages:
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_tool_preflight',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'message_count': len(plugin_preflight_messages),
|
||
}
|
||
)
|
||
if policy_preflight_messages:
|
||
stream_events.append(
|
||
{
|
||
'type': 'hook_policy_tool_preflight',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'message_count': len(policy_preflight_messages),
|
||
}
|
||
)
|
||
plugin_block_message = self._plugin_block_message(tool_call.name)
|
||
policy_block_message = self._hook_policy_block_message(tool_call.name)
|
||
if plugin_block_message is not None:
|
||
if self.plugin_runtime is not None:
|
||
blocked_attempts = int(
|
||
self.plugin_runtime.session_state.get('blocked_tool_attempts', 0)
|
||
)
|
||
self.plugin_runtime.session_state['blocked_tool_attempts'] = (
|
||
blocked_attempts + 1
|
||
)
|
||
tool_result = ToolExecutionResult(
|
||
name=tool_call.name,
|
||
ok=False,
|
||
content=plugin_block_message,
|
||
metadata={
|
||
'action': 'plugin_block',
|
||
'plugin_blocked': True,
|
||
'plugin_block_message': plugin_block_message,
|
||
},
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_tool_block',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'message': plugin_block_message,
|
||
}
|
||
)
|
||
if policy_block_message is not None:
|
||
tool_result = ToolExecutionResult(
|
||
name=tool_call.name,
|
||
ok=False,
|
||
content=policy_block_message,
|
||
metadata={
|
||
'action': 'hook_policy_block',
|
||
'hook_policy_blocked': True,
|
||
'hook_policy_block_message': policy_block_message,
|
||
'error_kind': 'permission_denied',
|
||
},
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'hook_policy_tool_block',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'message': policy_block_message,
|
||
}
|
||
)
|
||
if tool_call.name in ('Agent', 'delegate_agent'):
|
||
if tool_result is None:
|
||
tool_result = self._execute_delegate_agent(tool_call.arguments)
|
||
elif tool_call.name == 'Skill':
|
||
if tool_result is None:
|
||
tool_result = self._execute_skill(
|
||
tool_call.arguments,
|
||
active_skill_names=active_skill_names,
|
||
duplicate_skill_counts=duplicate_skill_counts,
|
||
)
|
||
elif tool_result is None:
|
||
for update in execute_tool_streaming(
|
||
self.tool_registry,
|
||
tool_call.name,
|
||
tool_call.arguments,
|
||
self.tool_context,
|
||
):
|
||
if update.kind == 'delta':
|
||
session.append_tool_delta(
|
||
tool_message_index,
|
||
update.content,
|
||
metadata={'last_stream': update.stream or 'tool'},
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'tool_delta',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'stream': update.stream,
|
||
'delta': update.content,
|
||
}
|
||
)
|
||
continue
|
||
tool_result = update.result
|
||
if tool_result is None:
|
||
raise RuntimeError(f'Tool executor returned no final result for {tool_call.name}')
|
||
if (
|
||
self.tool_context.cancel_event is not None
|
||
and self.tool_context.cancel_event.is_set()
|
||
):
|
||
result = AgentRunResult(
|
||
final_output='已取消当前请求。',
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='cancelled',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory)
|
||
if scratchpad_directory is not None
|
||
else None
|
||
),
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
if self.plugin_runtime is not None:
|
||
self.plugin_runtime.record_tool_result(
|
||
tool_call.name,
|
||
ok=tool_result.ok,
|
||
metadata=tool_result.metadata,
|
||
)
|
||
plugin_messages = self._plugin_tool_result_messages(tool_call.name)
|
||
policy_messages = self._hook_policy_tool_result_messages(tool_call.name)
|
||
if plugin_messages:
|
||
merged_metadata = dict(tool_result.metadata)
|
||
merged_metadata['plugin_messages'] = list(plugin_messages)
|
||
tool_result = ToolExecutionResult(
|
||
name=tool_result.name,
|
||
ok=tool_result.ok,
|
||
content=tool_result.content,
|
||
metadata=merged_metadata,
|
||
)
|
||
for message in plugin_messages:
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_tool_hook',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'message': message,
|
||
}
|
||
)
|
||
if policy_messages:
|
||
merged_metadata = dict(tool_result.metadata)
|
||
merged_metadata['hook_policy_messages'] = list(policy_messages)
|
||
tool_result = ToolExecutionResult(
|
||
name=tool_result.name,
|
||
ok=tool_result.ok,
|
||
content=tool_result.content,
|
||
metadata=merged_metadata,
|
||
)
|
||
for message in policy_messages:
|
||
stream_events.append(
|
||
{
|
||
'type': 'hook_policy_tool_hook',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'message': message,
|
||
}
|
||
)
|
||
if tool_result.metadata.get('error_kind') == 'permission_denied':
|
||
stream_events.append(
|
||
{
|
||
'type': 'tool_permission_denial',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'reason': tool_result.content,
|
||
'source': (
|
||
'hook_policy'
|
||
if tool_result.metadata.get('action') == 'hook_policy_block'
|
||
else 'tool_runtime'
|
||
),
|
||
}
|
||
)
|
||
session.finalize_tool(
|
||
tool_message_index,
|
||
content=serialize_tool_result(tool_result),
|
||
metadata={
|
||
'phase': 'completed',
|
||
'plugin_preflight_messages': list(plugin_preflight_messages),
|
||
'hook_policy_preflight_messages': list(policy_preflight_messages),
|
||
**dict(tool_result.metadata),
|
||
},
|
||
stop_reason='tool_completed',
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'tool_result',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'ok': tool_result.ok,
|
||
'metadata': dict(tool_result.metadata),
|
||
}
|
||
)
|
||
self._append_runtime_tool_followup_events(
|
||
stream_events,
|
||
tool_call=tool_call,
|
||
tool_result=tool_result,
|
||
)
|
||
plugin_runtime_message = self._build_plugin_tool_runtime_message(
|
||
tool_name=tool_call.name,
|
||
preflight_messages=plugin_preflight_messages,
|
||
block_message=plugin_block_message,
|
||
plugin_messages=plugin_messages,
|
||
hook_policy_preflight_messages=policy_preflight_messages,
|
||
hook_policy_block_message=policy_block_message,
|
||
hook_policy_messages=policy_messages,
|
||
delegate_preflight_messages=tuple(
|
||
message
|
||
for message in tool_result.metadata.get(
|
||
'plugin_delegate_preflight_messages',
|
||
[],
|
||
)
|
||
if isinstance(message, str) and message
|
||
),
|
||
delegate_after_messages=tuple(
|
||
message
|
||
for message in tool_result.metadata.get(
|
||
'plugin_delegate_after_messages',
|
||
[],
|
||
)
|
||
if isinstance(message, str) and message
|
||
),
|
||
)
|
||
if plugin_runtime_message is not None:
|
||
session.append_user(
|
||
plugin_runtime_message,
|
||
metadata={
|
||
'kind': 'plugin_tool_runtime',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'plugin_blocked': plugin_block_message is not None,
|
||
'plugin_message_count': len(plugin_messages),
|
||
'plugin_preflight_count': len(plugin_preflight_messages),
|
||
},
|
||
message_id=f'plugin_tool_runtime_{tool_call.id}',
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_tool_context',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': f'plugin_tool_runtime_{tool_call.id}',
|
||
'blocked': plugin_block_message is not None,
|
||
'message_count': len(plugin_messages),
|
||
'preflight_count': len(plugin_preflight_messages),
|
||
}
|
||
)
|
||
self._refresh_runtime_views_for_tool_result(tool_call.name, tool_result)
|
||
history_entry = self._build_file_history_entry(
|
||
tool_call=tool_call,
|
||
tool_result=tool_result,
|
||
turn_index=turn_index,
|
||
)
|
||
if history_entry is not None:
|
||
file_history.append(history_entry)
|
||
if tool_result.metadata.get('stop_agent') is True:
|
||
stop_reason = str(
|
||
tool_result.metadata.get('stop_reason') or 'tool_requested_stop'
|
||
)
|
||
stop_output = self._build_tool_requested_stop_output(tool_result)
|
||
stop_message_id = f'assistant_{len(session.messages)}'
|
||
session.append_assistant(
|
||
stop_output,
|
||
message_id=stop_message_id,
|
||
stop_reason=stop_reason,
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'tool_requested_stop',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'assistant_message_id': stop_message_id,
|
||
'metadata': dict(tool_result.metadata),
|
||
}
|
||
)
|
||
_append_final_text_stream_events(stream_events, stop_output)
|
||
result = AgentRunResult(
|
||
final_output=stop_output,
|
||
turns=turn_index,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason=stop_reason,
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=turn_index,
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
if tool_result.metadata.get('requires_user_review') is True:
|
||
review_output = self._build_user_review_required_output(tool_result)
|
||
review_message_id = f'assistant_{len(session.messages)}'
|
||
session.append_assistant(
|
||
review_output,
|
||
message_id=review_message_id,
|
||
stop_reason='user_review_required',
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'user_review_required',
|
||
'tool_name': tool_call.name,
|
||
'tool_call_id': tool_call.id,
|
||
'message_id': session.messages[tool_message_index].message_id,
|
||
'review_message_id': review_message_id,
|
||
'metadata': dict(tool_result.metadata),
|
||
}
|
||
)
|
||
result = AgentRunResult(
|
||
final_output=review_output,
|
||
turns=turn_index + 1,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='user_review_required',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=turn_index,
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
final_output = self._build_max_turns_output(
|
||
max_turns=self.runtime_config.max_turns,
|
||
last_content=last_content,
|
||
tool_calls=tool_calls,
|
||
)
|
||
session.append_assistant(
|
||
final_output,
|
||
message_id=f'assistant_{len(session.messages)}',
|
||
stop_reason='max_turns',
|
||
)
|
||
_append_final_text_stream_events(stream_events, final_output)
|
||
result = AgentRunResult(
|
||
final_output=final_output,
|
||
turns=self.runtime_config.max_turns,
|
||
tool_calls=tool_calls,
|
||
transcript=session.transcript(),
|
||
events=tuple(stream_events),
|
||
usage=total_usage,
|
||
total_cost_usd=total_cost_usd,
|
||
stop_reason='max_turns',
|
||
file_history=tuple(file_history),
|
||
session_id=session_id,
|
||
scratchpad_directory=(
|
||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||
),
|
||
)
|
||
result = self._append_runtime_after_turn_events(
|
||
result,
|
||
prompt=effective_prompt,
|
||
turn_index=self.runtime_config.max_turns,
|
||
)
|
||
result = self._persist_session(session, result)
|
||
self.last_run_result = result
|
||
return result
|
||
|
||
@staticmethod
|
||
def _build_max_turns_output(
|
||
*,
|
||
max_turns: int,
|
||
last_content: str,
|
||
tool_calls: int,
|
||
) -> str:
|
||
lines = [
|
||
f'已达到本轮最大步骤上限({max_turns} 轮),为了避免继续空转,当前任务已暂停。',
|
||
]
|
||
if tool_calls:
|
||
lines.append('最后一轮已经执行完工具,但还没有来得及整理成最终回复。')
|
||
if last_content.strip():
|
||
lines.append(f'最后一次阶段说明:{last_content.strip()}')
|
||
lines.append('你可以继续补充指令,我会基于当前会话结果接着处理。')
|
||
return '\n\n'.join(lines)
|
||
|
||
def _query_model(
|
||
self,
|
||
session: AgentSessionState,
|
||
tool_specs: list[dict[str, object]],
|
||
*,
|
||
stream_events: list[dict[str, object]] | None = None,
|
||
) -> tuple[AssistantTurn, tuple[StreamEvent, ...]]:
|
||
if not self.runtime_config.stream_model_responses:
|
||
turn = self.client.complete(
|
||
session.to_openai_messages(),
|
||
tool_specs,
|
||
output_schema=self.runtime_config.output_schema,
|
||
)
|
||
assistant_tool_calls = tuple(
|
||
{
|
||
'id': tool_call.id,
|
||
'type': 'function',
|
||
'function': {
|
||
'name': tool_call.name,
|
||
'arguments': json.dumps(
|
||
tool_call.arguments,
|
||
ensure_ascii=True,
|
||
),
|
||
},
|
||
}
|
||
for tool_call in turn.tool_calls
|
||
)
|
||
session.append_assistant(
|
||
turn.content,
|
||
assistant_tool_calls,
|
||
message_id=f'assistant_{len(session.messages)}',
|
||
stop_reason=turn.finish_reason,
|
||
usage=turn.usage,
|
||
)
|
||
return turn, ()
|
||
|
||
assistant_index = session.start_assistant(
|
||
message_id=f'assistant_{len(session.messages)}'
|
||
)
|
||
usage = UsageStats()
|
||
finish_reason: str | None = None
|
||
events: list[StreamEvent] = []
|
||
for event in self.client.stream(
|
||
session.to_openai_messages(),
|
||
tool_specs,
|
||
output_schema=self.runtime_config.output_schema,
|
||
):
|
||
if stream_events is None:
|
||
events.append(event)
|
||
else:
|
||
stream_events.append(event.to_dict())
|
||
if event.type == 'content_delta':
|
||
session.append_assistant_delta(assistant_index, event.delta)
|
||
elif event.type == 'tool_call_delta':
|
||
session.merge_assistant_tool_call_delta(
|
||
assistant_index,
|
||
tool_call_index=event.tool_call_index or 0,
|
||
tool_call_id=event.tool_call_id,
|
||
tool_name=event.tool_name,
|
||
arguments_delta=event.arguments_delta,
|
||
)
|
||
elif event.type == 'usage':
|
||
usage = usage + event.usage
|
||
elif event.type == 'message_stop':
|
||
finish_reason = event.finish_reason
|
||
|
||
session.finalize_assistant(
|
||
assistant_index,
|
||
finish_reason=finish_reason,
|
||
usage=usage,
|
||
)
|
||
assistant_message = session.messages[assistant_index]
|
||
try:
|
||
parsed_tool_calls = self._tool_calls_from_message(assistant_message.tool_calls)
|
||
except OpenAICompatError:
|
||
self._scrub_malformed_assistant_tool_calls(session, assistant_index)
|
||
raise
|
||
turn = AssistantTurn(
|
||
content=assistant_message.content,
|
||
tool_calls=parsed_tool_calls,
|
||
finish_reason=finish_reason,
|
||
raw_message=assistant_message.to_openai_message(),
|
||
usage=usage,
|
||
)
|
||
return turn, tuple(events)
|
||
|
||
def _scrub_malformed_assistant_tool_calls(
|
||
self,
|
||
session: AgentSessionState,
|
||
assistant_index: int,
|
||
) -> None:
|
||
message = session.messages[assistant_index]
|
||
filtered_blocks = tuple(
|
||
block
|
||
for block in message.blocks
|
||
if block.get('type') not in {'tool_use', 'tool_call'}
|
||
)
|
||
session.messages[assistant_index] = replace(
|
||
message,
|
||
tool_calls=(),
|
||
blocks=filtered_blocks,
|
||
stop_reason='malformed_tool_arguments',
|
||
metadata={
|
||
**message.metadata,
|
||
'malformed_tool_arguments': True,
|
||
},
|
||
)
|
||
|
||
def _tool_calls_from_message(
|
||
self,
|
||
tool_calls: tuple[dict[str, object], ...],
|
||
) -> tuple[ToolCall, ...]:
|
||
parsed: list[ToolCall] = []
|
||
for index, raw_tool_call in enumerate(tool_calls):
|
||
function_block = raw_tool_call.get('function')
|
||
if not isinstance(function_block, dict):
|
||
continue
|
||
name = function_block.get('name')
|
||
if not isinstance(name, str) or not name:
|
||
continue
|
||
raw_arguments = function_block.get('arguments', '')
|
||
if isinstance(raw_arguments, str) and raw_arguments.strip():
|
||
try:
|
||
arguments = json.loads(raw_arguments)
|
||
except json.JSONDecodeError as exc:
|
||
preview = raw_arguments[:240].replace('\n', '\\n')
|
||
raise OpenAICompatError(
|
||
'模型返回的工具参数不是合法 JSON,已停止本轮以避免执行错误工具。'
|
||
f'工具:{name};位置:line {exc.lineno} column {exc.colno};'
|
||
f'原因:{exc.msg};参数片段:{preview!r}。'
|
||
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
||
'如果是 write_file 写短文本/Markdown,应改用 content_lines;'
|
||
'如果是很小的 JSON/JSONL/CSV,可用 json_content、jsonl_records 或 csv_rows;'
|
||
'如果是脚本、长文本、大 JSON、JSONL、CSV 或生成数据,应改用 bash 配合 quoted heredoc 写文件,'
|
||
'或先 write_file 落地一个小 .py 再用 bash 跑;'
|
||
'不要把大段内容直接塞进工具参数。'
|
||
) from exc
|
||
if not isinstance(arguments, dict):
|
||
raise OpenAICompatError(
|
||
f'Tool arguments must decode to an object, got {type(arguments).__name__}'
|
||
)
|
||
else:
|
||
arguments = {}
|
||
call_id = raw_tool_call.get('id')
|
||
if not isinstance(call_id, str) or not call_id:
|
||
call_id = f'call_{index}'
|
||
parsed.append(
|
||
ToolCall(
|
||
id=call_id,
|
||
name=name,
|
||
arguments=arguments,
|
||
)
|
||
)
|
||
return tuple(parsed)
|
||
|
||
def _should_continue_response(
|
||
self,
|
||
turn: AssistantTurn,
|
||
*,
|
||
continuation_count: int = 0,
|
||
) -> bool:
|
||
if turn.finish_reason not in CONTINUATION_FINISH_REASONS:
|
||
return False
|
||
if continuation_count >= MAX_AUTO_CONTINUATIONS:
|
||
return False
|
||
return bool((turn.content or '').strip())
|
||
|
||
def _is_empty_truncated_response(self, turn: AssistantTurn) -> bool:
|
||
return (
|
||
turn.finish_reason in CONTINUATION_FINISH_REASONS
|
||
and not (turn.content or '').strip()
|
||
and not turn.tool_calls
|
||
)
|
||
|
||
def _reached_auto_continuation_limit(
|
||
self,
|
||
turn: AssistantTurn,
|
||
*,
|
||
continuation_count: int,
|
||
) -> bool:
|
||
return (
|
||
turn.finish_reason in CONTINUATION_FINISH_REASONS
|
||
and bool((turn.content or '').strip())
|
||
and continuation_count >= MAX_AUTO_CONTINUATIONS
|
||
)
|
||
|
||
def _append_continuation_limit_note(self, text: str) -> str:
|
||
note = (
|
||
f'[系统提示] 已达到自动续写上限 {MAX_AUTO_CONTINUATIONS} 次,'
|
||
'本轮先暂停,避免模型无限续写。你可以回复“继续”再接着处理。'
|
||
)
|
||
if not text.strip():
|
||
return note
|
||
return f'{text.rstrip()}\n\n{note}'
|
||
|
||
def _build_empty_truncated_response_output(self) -> str:
|
||
return (
|
||
'模型返回了空的截断响应,本轮已暂停。\n\n'
|
||
'这通常表示模型后端输出被截断但没有返回可展示内容。'
|
||
'你可以回复“继续”让我接着处理,或补充更具体的指令重新发起。'
|
||
)
|
||
|
||
def _build_continuation_prompt(self) -> str:
|
||
return (
|
||
'<system-reminder>\n'
|
||
'Your previous answer was truncated because the model stopped early. '
|
||
'Continue exactly where you left off. Do not repeat completed text.\n'
|
||
'</system-reminder>'
|
||
)
|
||
|
||
def _check_budget(
|
||
self,
|
||
usage: UsageStats,
|
||
total_cost_usd: float,
|
||
*,
|
||
tool_calls: int,
|
||
delegated_tasks: int,
|
||
model_calls: int,
|
||
session_turns: int,
|
||
) -> BudgetDecision:
|
||
budget = self.runtime_config.budget_config
|
||
token_reason = self._check_token_budget(usage, budget)
|
||
if token_reason is not None:
|
||
return BudgetDecision(exceeded=True, reason=token_reason)
|
||
if (
|
||
budget.max_total_cost_usd is not None
|
||
and total_cost_usd > budget.max_total_cost_usd
|
||
):
|
||
return BudgetDecision(
|
||
exceeded=True,
|
||
reason=(
|
||
'Stopped because the total estimated cost '
|
||
f'(${total_cost_usd:.6f}) exceeded the configured budget '
|
||
f'(${budget.max_total_cost_usd:.6f}).'
|
||
),
|
||
)
|
||
if (
|
||
budget.max_tool_calls is not None
|
||
and tool_calls > budget.max_tool_calls
|
||
):
|
||
return BudgetDecision(
|
||
exceeded=True,
|
||
reason=(
|
||
'Stopped because the tool-call budget was exceeded '
|
||
f'({tool_calls} > {budget.max_tool_calls}).'
|
||
),
|
||
)
|
||
if (
|
||
budget.max_delegated_tasks is not None
|
||
and delegated_tasks > budget.max_delegated_tasks
|
||
):
|
||
return BudgetDecision(
|
||
exceeded=True,
|
||
reason=(
|
||
'Stopped because the delegated-task budget was exceeded '
|
||
f'({delegated_tasks} > {budget.max_delegated_tasks}).'
|
||
),
|
||
)
|
||
if (
|
||
budget.max_model_calls is not None
|
||
and model_calls > budget.max_model_calls
|
||
):
|
||
return BudgetDecision(
|
||
exceeded=True,
|
||
reason=(
|
||
'Stopped because the model-call budget was exceeded '
|
||
f'({model_calls} > {budget.max_model_calls}).'
|
||
),
|
||
)
|
||
if (
|
||
budget.max_session_turns is not None
|
||
and session_turns > budget.max_session_turns
|
||
):
|
||
return BudgetDecision(
|
||
exceeded=True,
|
||
reason=(
|
||
'Stopped because the session-turn budget was exceeded '
|
||
f'({session_turns} > {budget.max_session_turns}).'
|
||
),
|
||
)
|
||
return BudgetDecision(exceeded=False)
|
||
|
||
def _preflight_prompt_length(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
) -> PromptPreflightResult:
|
||
snapshot = calculate_token_budget(
|
||
session=session,
|
||
model=self.model_config.model,
|
||
budget_config=self.runtime_config.budget_config,
|
||
output_schema=self.runtime_config.output_schema,
|
||
)
|
||
if not snapshot.exceeds_soft_limit and not snapshot.exceeds_hard_limit:
|
||
return PromptPreflightResult()
|
||
|
||
stream_events.append(
|
||
{
|
||
'type': 'prompt_length_check',
|
||
'turn_index': turn_index,
|
||
'projected_input_tokens': snapshot.projected_input_tokens,
|
||
'soft_input_limit_tokens': snapshot.soft_input_limit_tokens,
|
||
'hard_input_limit_tokens': snapshot.hard_input_limit_tokens,
|
||
'soft_overflow_tokens': snapshot.soft_overflow_tokens,
|
||
'overflow_tokens': snapshot.overflow_tokens,
|
||
'exceeds_hard_limit': snapshot.exceeds_hard_limit,
|
||
}
|
||
)
|
||
|
||
target_tokens = snapshot.soft_input_limit_tokens
|
||
if snapshot.exceeds_hard_limit:
|
||
target_tokens = snapshot.hard_input_limit_tokens
|
||
if target_tokens < 0:
|
||
target_tokens = 0
|
||
|
||
if self._reduce_context_pressure(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
target_tokens=target_tokens,
|
||
allow_compaction=True,
|
||
):
|
||
recovered = calculate_token_budget(
|
||
session=session,
|
||
model=self.model_config.model,
|
||
budget_config=self.runtime_config.budget_config,
|
||
output_schema=self.runtime_config.output_schema,
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'prompt_length_recovery',
|
||
'turn_index': turn_index,
|
||
'strategy': 'heuristic',
|
||
'projected_input_tokens': recovered.projected_input_tokens,
|
||
'soft_input_limit_tokens': recovered.soft_input_limit_tokens,
|
||
'hard_input_limit_tokens': recovered.hard_input_limit_tokens,
|
||
'exceeds_hard_limit': recovered.exceeds_hard_limit,
|
||
'exceeds_soft_limit': recovered.exceeds_soft_limit,
|
||
}
|
||
)
|
||
if not recovered.exceeds_soft_limit and not recovered.exceeds_hard_limit:
|
||
return PromptPreflightResult()
|
||
snapshot = recovered
|
||
|
||
# Circuit-breaker: skip auto-compact after MAX_COMPACT_FAILURES consecutive failures
|
||
from .compact import MAX_COMPACT_FAILURES
|
||
if self._compact_consecutive_failures >= MAX_COMPACT_FAILURES:
|
||
stream_events.append(
|
||
{
|
||
'type': 'auto_compact_circuit_breaker',
|
||
'turn_index': turn_index,
|
||
'consecutive_failures': self._compact_consecutive_failures,
|
||
}
|
||
)
|
||
elif self._can_auto_compact_with_summary(session):
|
||
compact_result = compact_conversation(
|
||
self,
|
||
custom_instructions=(
|
||
'Automatically collapse earlier conversation context to fit the next model '
|
||
'turn. Preserve the active task, recent file changes, failures, pending work, '
|
||
'and exact next step.'
|
||
),
|
||
)
|
||
if compact_result.error is None:
|
||
self._compact_consecutive_failures = 0 # Reset on success
|
||
recovered = calculate_token_budget(
|
||
session=session,
|
||
model=self.model_config.model,
|
||
budget_config=self.runtime_config.budget_config,
|
||
output_schema=self.runtime_config.output_schema,
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'auto_compact_summary',
|
||
'turn_index': turn_index,
|
||
'pre_compact_token_count': compact_result.pre_compact_token_count,
|
||
'post_compact_token_count': compact_result.post_compact_token_count,
|
||
'true_post_compact_token_count': compact_result.true_post_compact_token_count,
|
||
'summary_usage_tokens': compact_result.usage.total_tokens,
|
||
'ptl_retries': compact_result.ptl_retries,
|
||
'projected_input_tokens': recovered.projected_input_tokens,
|
||
'soft_input_limit_tokens': recovered.soft_input_limit_tokens,
|
||
'hard_input_limit_tokens': recovered.hard_input_limit_tokens,
|
||
'exceeds_hard_limit': recovered.exceeds_hard_limit,
|
||
'exceeds_soft_limit': recovered.exceeds_soft_limit,
|
||
}
|
||
)
|
||
if not recovered.exceeds_soft_limit and not recovered.exceeds_hard_limit:
|
||
return PromptPreflightResult(
|
||
usage_increment=compact_result.usage,
|
||
model_calls_increment=1,
|
||
)
|
||
snapshot = recovered
|
||
if compact_result.usage.total_tokens:
|
||
return PromptPreflightResult(
|
||
usage_increment=compact_result.usage,
|
||
model_calls_increment=1,
|
||
stop_reason=(
|
||
'prompt_too_long'
|
||
if recovered.exceeds_hard_limit
|
||
else None
|
||
),
|
||
reason=(
|
||
self._build_prompt_length_error(recovered)
|
||
if recovered.exceeds_hard_limit
|
||
else None
|
||
),
|
||
)
|
||
else:
|
||
self._compact_consecutive_failures += 1
|
||
stream_events.append(
|
||
{
|
||
'type': 'auto_compact_failed',
|
||
'turn_index': turn_index,
|
||
'reason': compact_result.error,
|
||
'consecutive_failures': self._compact_consecutive_failures,
|
||
}
|
||
)
|
||
|
||
if snapshot.exceeds_hard_limit:
|
||
return PromptPreflightResult(
|
||
stop_reason='prompt_too_long',
|
||
reason=self._build_prompt_length_error(snapshot),
|
||
)
|
||
|
||
stream_events.append(
|
||
{
|
||
'type': 'prompt_length_warning',
|
||
'turn_index': turn_index,
|
||
'projected_input_tokens': snapshot.projected_input_tokens,
|
||
'soft_input_limit_tokens': snapshot.soft_input_limit_tokens,
|
||
'hard_input_limit_tokens': snapshot.hard_input_limit_tokens,
|
||
'soft_overflow_tokens': snapshot.soft_overflow_tokens,
|
||
}
|
||
)
|
||
return PromptPreflightResult()
|
||
|
||
def _can_auto_compact_with_summary(self, session: AgentSessionState) -> bool:
|
||
prefix_count = self._compact_prefix_count(session)
|
||
preserve_count = max(self.runtime_config.compact_preserve_messages, 1)
|
||
return len(session.messages) - prefix_count > preserve_count
|
||
|
||
def _build_prompt_length_error(self, snapshot) -> str:
|
||
return (
|
||
'Stopped before the next model call because the prompt would exceed the '
|
||
'effective input budget. '
|
||
f'Projected prompt tokens: {snapshot.projected_input_tokens:,}; '
|
||
f'hard input limit: {snapshot.hard_input_limit_tokens:,}; '
|
||
f'soft input limit: {snapshot.soft_input_limit_tokens:,}.'
|
||
)
|
||
|
||
def _microcompact_session_if_needed(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
) -> None:
|
||
"""Run time-based microcompaction to clear old tool results.
|
||
|
||
Fires when the gap since the last assistant message exceeds the
|
||
threshold (default 60 minutes), indicating the server-side cache
|
||
has expired and the full prefix will be rewritten anyway.
|
||
"""
|
||
if not session.messages:
|
||
return
|
||
result = _microcompact_messages(
|
||
session.messages,
|
||
model=self.model_config.model,
|
||
)
|
||
if not result.triggered:
|
||
return
|
||
session.messages = result.messages
|
||
stream_events.append(
|
||
{
|
||
'type': 'microcompact',
|
||
'turn_index': turn_index,
|
||
'cleared_tool_count': result.cleared_tool_count,
|
||
'kept_tool_count': result.kept_tool_count,
|
||
'estimated_tokens_saved': result.estimated_tokens_saved,
|
||
'gap_minutes': round(result.gap_minutes, 1),
|
||
}
|
||
)
|
||
|
||
def _snip_session_if_needed(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
) -> None:
|
||
threshold = self.runtime_config.auto_snip_threshold_tokens
|
||
if threshold is None or threshold <= 0:
|
||
return
|
||
self._reduce_context_pressure(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
target_tokens=threshold,
|
||
allow_compaction=False,
|
||
)
|
||
|
||
def _compact_session_if_needed(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
) -> None:
|
||
threshold = self.runtime_config.auto_compact_threshold_tokens
|
||
if threshold is None or threshold <= 0:
|
||
return
|
||
self._reduce_context_pressure(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
target_tokens=threshold,
|
||
allow_compaction=True,
|
||
)
|
||
|
||
def _reactive_compact_session(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
) -> bool:
|
||
return self._reduce_context_pressure(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
target_tokens=0,
|
||
allow_compaction=True,
|
||
reactive=True,
|
||
)
|
||
|
||
def _reduce_context_pressure(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
target_tokens: int,
|
||
allow_compaction: bool,
|
||
reactive: bool = False,
|
||
) -> bool:
|
||
changed = False
|
||
for _ in range(6):
|
||
usage_report = collect_context_usage(
|
||
session=session,
|
||
model=self.model_config.model,
|
||
strategy='reactive_compact' if reactive else 'context_pressure',
|
||
)
|
||
if usage_report.total_tokens <= target_tokens:
|
||
break
|
||
if self._snip_session_pass(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
target_tokens=target_tokens,
|
||
current_total=usage_report.total_tokens,
|
||
reactive=reactive,
|
||
):
|
||
changed = True
|
||
continue
|
||
if allow_compaction and self._compact_session_pass(
|
||
session,
|
||
stream_events,
|
||
turn_index=turn_index,
|
||
usage_total=usage_report.total_tokens,
|
||
reactive=reactive,
|
||
):
|
||
changed = True
|
||
if reactive:
|
||
continue
|
||
break
|
||
break
|
||
return changed
|
||
|
||
def _snip_session_pass(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
target_tokens: int,
|
||
current_total: int,
|
||
reactive: bool,
|
||
) -> bool:
|
||
prefix_count = self._compact_prefix_count(session)
|
||
tail_count = min(
|
||
max(self.runtime_config.compact_preserve_messages, 0),
|
||
max(len(session.messages) - prefix_count, 0),
|
||
)
|
||
candidate_indexes = [
|
||
index
|
||
for index in range(prefix_count, max(len(session.messages) - tail_count, prefix_count))
|
||
if self._message_can_be_snipped(session.messages[index])
|
||
]
|
||
if not candidate_indexes:
|
||
return False
|
||
snipped_count = 0
|
||
tokens_removed = 0
|
||
snipped_message_ids: list[str] = []
|
||
for index in candidate_indexes:
|
||
if current_total <= target_tokens and not reactive:
|
||
break
|
||
message = session.messages[index]
|
||
original_tokens = estimate_tokens(message.content, self.model_config.model)
|
||
replacement = self._build_snipped_message_content(message)
|
||
replacement_tokens = estimate_tokens(replacement, self.model_config.model)
|
||
if replacement_tokens >= original_tokens:
|
||
continue
|
||
session.tombstone_message(
|
||
index,
|
||
summary=replacement,
|
||
stop_reason='snipped_for_context',
|
||
mutation_kind='snip_tombstone',
|
||
metadata={
|
||
'kind': 'snipped_message',
|
||
'original_token_estimate': original_tokens,
|
||
'replacement_token_estimate': replacement_tokens,
|
||
'snipped_turn_index': turn_index,
|
||
'snipped_from_role': message.role,
|
||
'snipped_from_message_id': message.message_id,
|
||
'snipped_from_kind': message.metadata.get('kind'),
|
||
'snipped_from_lineage_id': message.metadata.get('lineage_id'),
|
||
'snipped_from_revision': message.metadata.get('revision'),
|
||
},
|
||
)
|
||
delta = original_tokens - replacement_tokens
|
||
current_total -= delta
|
||
tokens_removed += delta
|
||
snipped_count += 1
|
||
if session.messages[index].message_id:
|
||
snipped_message_ids.append(session.messages[index].message_id)
|
||
if reactive and snipped_count >= 3:
|
||
break
|
||
if not snipped_count:
|
||
return False
|
||
stream_events.append(
|
||
{
|
||
'type': 'reactive_snip_boundary' if reactive else 'snip_boundary',
|
||
'turn_index': turn_index,
|
||
'snipped_message_count': snipped_count,
|
||
'estimated_tokens_removed': tokens_removed,
|
||
'snipped_message_ids': snipped_message_ids,
|
||
}
|
||
)
|
||
return True
|
||
|
||
def _compact_session_pass(
|
||
self,
|
||
session: AgentSessionState,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
turn_index: int,
|
||
usage_total: int,
|
||
reactive: bool,
|
||
) -> bool:
|
||
prefix_count = self._compact_prefix_count(session)
|
||
preserve_messages = max(self.runtime_config.compact_preserve_messages, 0)
|
||
if reactive:
|
||
preserve_messages = max(preserve_messages // 2, 1)
|
||
tail_count = min(
|
||
preserve_messages,
|
||
max(len(session.messages) - prefix_count, 0),
|
||
)
|
||
compact_end = len(session.messages) - tail_count
|
||
if compact_end <= prefix_count:
|
||
return False
|
||
candidates = session.messages[prefix_count:compact_end]
|
||
preserved_tail = list(session.messages[compact_end:])
|
||
if not candidates:
|
||
return False
|
||
compacted_tokens = sum(
|
||
usage.tokens
|
||
for usage in (
|
||
collect_context_usage(
|
||
session=AgentSessionState(
|
||
system_prompt_parts=session.system_prompt_parts,
|
||
user_context=session.user_context,
|
||
system_context=session.system_context,
|
||
messages=list(candidates),
|
||
),
|
||
model=self.model_config.model,
|
||
strategy='compacted_segment',
|
||
).categories
|
||
)
|
||
if usage.name != 'Free space'
|
||
)
|
||
compact_message = self._build_compact_boundary_message(
|
||
candidates,
|
||
turn_index=turn_index,
|
||
estimated_tokens_before=usage_total,
|
||
estimated_tokens_removed=compacted_tokens,
|
||
preserved_tail_count=tail_count,
|
||
preserved_tail=preserved_tail,
|
||
)
|
||
session.messages = (
|
||
session.messages[:prefix_count]
|
||
+ [compact_message]
|
||
+ session.messages[compact_end:]
|
||
)
|
||
stream_events.append(
|
||
{
|
||
'type': 'reactive_compact_boundary' if reactive else 'compact_boundary',
|
||
'turn_index': turn_index,
|
||
'compacted_message_count': len(candidates),
|
||
'estimated_tokens_before': usage_total,
|
||
'estimated_tokens_removed': compacted_tokens,
|
||
'preserved_tail_count': tail_count,
|
||
'preserved_tail_ids': [
|
||
message.message_id for message in preserved_tail if message.message_id
|
||
],
|
||
'compaction_depth': compact_message.metadata.get('compaction_depth'),
|
||
'nested_compaction_count': compact_message.metadata.get('nested_compaction_count'),
|
||
'compacted_message_ids': [
|
||
message.message_id for message in candidates if message.message_id
|
||
],
|
||
}
|
||
)
|
||
return True
|
||
|
||
def _check_token_budget(
|
||
self,
|
||
usage: UsageStats,
|
||
budget: BudgetConfig,
|
||
) -> str | None:
|
||
if budget.max_total_tokens is not None and usage.total_tokens > budget.max_total_tokens:
|
||
return (
|
||
'Stopped because the total token budget was exceeded '
|
||
f'({usage.total_tokens} > {budget.max_total_tokens}).'
|
||
)
|
||
if budget.max_input_tokens is not None and usage.input_tokens > budget.max_input_tokens:
|
||
return (
|
||
'Stopped because the input token budget was exceeded '
|
||
f'({usage.input_tokens} > {budget.max_input_tokens}).'
|
||
)
|
||
if budget.max_output_tokens is not None and usage.output_tokens > budget.max_output_tokens:
|
||
return (
|
||
'Stopped because the output token budget was exceeded '
|
||
f'({usage.output_tokens} > {budget.max_output_tokens}).'
|
||
)
|
||
if (
|
||
budget.max_reasoning_tokens is not None
|
||
and usage.reasoning_tokens > budget.max_reasoning_tokens
|
||
):
|
||
return (
|
||
'Stopped because the reasoning token budget was exceeded '
|
||
f'({usage.reasoning_tokens} > {budget.max_reasoning_tokens}).'
|
||
)
|
||
return None
|
||
|
||
def _build_file_history_entry(
|
||
self,
|
||
*,
|
||
tool_call: ToolCall,
|
||
tool_result,
|
||
turn_index: int,
|
||
) -> dict[str, object] | None:
|
||
if not tool_result.metadata:
|
||
return None
|
||
if (
|
||
'path' not in tool_result.metadata
|
||
and 'command' not in tool_result.metadata
|
||
and tool_result.metadata.get('action') not in ('delegate_agent', 'Agent')
|
||
):
|
||
return None
|
||
metadata = dict(tool_result.metadata)
|
||
entry: dict[str, object] = {
|
||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||
'turn_index': turn_index,
|
||
'tool_call_id': tool_call.id,
|
||
'tool_name': tool_call.name,
|
||
'ok': tool_result.ok,
|
||
'history_entry_id': f'{turn_index}:{tool_call.id}:{tool_call.name}',
|
||
'result_preview': self._preview_text(tool_result.content, 220),
|
||
**metadata,
|
||
}
|
||
action = metadata.get('action')
|
||
path = metadata.get('path')
|
||
if isinstance(path, str) and path:
|
||
entry['history_kind'] = 'file_change'
|
||
entry['changed_paths'] = [path]
|
||
before_sha256 = metadata.get('before_sha256')
|
||
if isinstance(before_sha256, str) and before_sha256:
|
||
entry['before_snapshot_id'] = f'{path}:{before_sha256[:12]}'
|
||
after_sha256 = metadata.get('after_sha256')
|
||
if isinstance(after_sha256, str) and after_sha256:
|
||
entry['after_snapshot_id'] = f'{path}:{after_sha256[:12]}'
|
||
elif isinstance(metadata.get('command'), str):
|
||
entry['history_kind'] = 'shell'
|
||
elif action in ('delegate_agent', 'Agent'):
|
||
entry['history_kind'] = 'delegation'
|
||
delegate_batches = metadata.get('delegate_batches')
|
||
if isinstance(delegate_batches, list):
|
||
entry['delegate_batch_count'] = len(delegate_batches)
|
||
dependency_skips = metadata.get('dependency_skips')
|
||
if isinstance(dependency_skips, int) and not isinstance(dependency_skips, bool):
|
||
entry['dependency_skips'] = dependency_skips
|
||
else:
|
||
entry['history_kind'] = 'tool'
|
||
return entry
|
||
|
||
def _build_tool_requested_stop_output(self, tool_result: ToolExecutionResult) -> str:
|
||
stop_reason = str(tool_result.metadata.get('stop_reason') or '')
|
||
if stop_reason == 'duplicate_skill_loop':
|
||
skill_name = str(tool_result.metadata.get('skill_name') or 'unknown')
|
||
duplicate_count = tool_result.metadata.get('duplicate_count')
|
||
return (
|
||
f'检测到模型连续重复调用 Skill `{skill_name}`,本轮已暂停。\n\n'
|
||
f'- 重复次数:{duplicate_count}\n'
|
||
'- 已处理:首次 Skill 已成功注入,后续重复调用没有继续注入大段提示词。\n'
|
||
'- 建议:你可以直接回复“继续”,我会基于已经激活的 Skill 往下推进;'
|
||
'或者补充更明确的生成边界和数量。'
|
||
)
|
||
return (
|
||
'工具请求暂停当前任务,本轮已停止。\n\n'
|
||
f'- 工具:{tool_result.name}\n'
|
||
f'- 原因:{tool_result.content}'
|
||
)
|
||
|
||
def _build_user_review_required_output(self, tool_result: ToolExecutionResult) -> str:
|
||
fallback = (
|
||
'已生成待 review 的计划。本轮已暂停,请检查上面的计划;'
|
||
'如果需要修改,请直接回复修改意见;如果认可,请回复“确认,开始生成”。'
|
||
)
|
||
try:
|
||
payload = json.loads(tool_result.content)
|
||
except json.JSONDecodeError:
|
||
return fallback
|
||
if not isinstance(payload, dict):
|
||
return fallback
|
||
goal = payload.get('goal')
|
||
if isinstance(goal, dict):
|
||
return self._format_generation_goal_review(goal)
|
||
plan = payload.get('plan')
|
||
if isinstance(plan, dict):
|
||
return self._format_generation_plan_review(plan)
|
||
ask_user = payload.get('ask_user')
|
||
if isinstance(ask_user, dict):
|
||
return self._format_ask_user_review(ask_user)
|
||
return fallback
|
||
|
||
def _format_ask_user_review(self, ask_user: dict[str, object]) -> str:
|
||
question = str(ask_user.get('question') or '').strip()
|
||
header = str(ask_user.get('header') or '').strip()
|
||
question_id = str(ask_user.get('question_id') or '').strip()
|
||
choices = ask_user.get('choices')
|
||
lines: list[str] = []
|
||
if header:
|
||
lines.append(header)
|
||
lines.append('')
|
||
lines.append(question or '需要你补充一个回答后才能继续。')
|
||
if isinstance(choices, list) and choices:
|
||
rendered_choices = [
|
||
str(choice).strip()
|
||
for choice in choices
|
||
if isinstance(choice, str) and str(choice).strip()
|
||
]
|
||
if rendered_choices:
|
||
lines.append('')
|
||
lines.append('请选择一个选项,或直接回复你的补充说明:')
|
||
for index, choice in enumerate(rendered_choices, start=1):
|
||
lines.append(f'{index}. {choice}')
|
||
else:
|
||
lines.append('')
|
||
lines.append('请直接回复你的答案,我会继续执行。')
|
||
if question_id:
|
||
lines.append('')
|
||
lines.append(f'内部问题 ID:`{question_id}`')
|
||
return '\n'.join(lines)
|
||
|
||
def _format_generation_goal_review(self, goal: dict[str, object]) -> str:
|
||
target_summary = self._format_review_targets(goal)
|
||
lines = [
|
||
'我先把生成目标整理好了,先确认边界,暂时不生成数据。',
|
||
f"- 数据集:{goal.get('dataset_label', '')}",
|
||
]
|
||
if target_summary:
|
||
lines.append(f'- 标签:{target_summary}')
|
||
if goal.get('goal_summary'):
|
||
lines.append(f"- 边界:{self._short_review_text(goal.get('goal_summary'))}")
|
||
scope_parts = []
|
||
if goal.get('coverage'):
|
||
scope_parts.append(f"覆盖:{self._short_review_text(goal.get('coverage'), limit=70)}")
|
||
if goal.get('exclusions'):
|
||
scope_parts.append(f"排除:{self._short_review_text(goal.get('exclusions'), limit=50)}")
|
||
if scope_parts:
|
||
lines.append('- 范围:' + ';'.join(scope_parts))
|
||
open_questions = goal.get('open_questions')
|
||
if isinstance(open_questions, list) and open_questions:
|
||
questions = ';'.join(str(item).strip() for item in open_questions if isinstance(item, str) and item.strip())
|
||
if questions:
|
||
lines.append(f'- 待确认:{self._short_review_text(questions)}')
|
||
elif goal.get('plan_hint'):
|
||
lines.append(f"- 下一步:{self._short_review_text(goal.get('plan_hint'))}")
|
||
lines.append(f"- 内部:goal_id `{goal.get('goal_id', '')}`,revision `{goal.get('revision', '')}`")
|
||
lines.append('')
|
||
lines.append('你看这个目标是否准确?没问题就回“确认目标”;想改的话直接说哪里不对。')
|
||
return '\n'.join(lines)
|
||
|
||
def _format_generation_plan_review(self, plan: dict[str, object]) -> str:
|
||
if not plan.get('confirmed_goal_id'):
|
||
return self._format_direct_generation_plan_review(plan)
|
||
lines = [
|
||
'目标已确认。现在只补充生成参数,标签边界沿用上一步。',
|
||
f"- 数量:{plan.get('total_count', '')} 条",
|
||
f"- 轮次:{plan.get('turn_mix', '')}",
|
||
f"- 覆盖补充:{self._short_review_text(plan.get('coverage'))}",
|
||
f"- 排除:{self._short_review_text(plan.get('exclusions'))}",
|
||
f"- 输出:{plan.get('output_path', '')}",
|
||
f"- 内部:plan_id `{plan.get('plan_id', '')}`,revision `{plan.get('revision', '')}`",
|
||
]
|
||
lines.append('')
|
||
lines.append('如果这个数量和路径可以,就回“确认,开始生成”;想调整就直接说,比如“改成 20 条,全单轮”。')
|
||
return '\n'.join(lines)
|
||
|
||
def _format_direct_generation_plan_review(self, plan: dict[str, object]) -> str:
|
||
target_summary = self._format_review_targets(plan)
|
||
lines = [
|
||
'我把目标和生成参数合成一次确认,确认后就开始生成。',
|
||
f"- 数据集:{plan.get('dataset_label', '')}",
|
||
]
|
||
if target_summary:
|
||
lines.append(f'- 标签:{target_summary}')
|
||
lines.extend(
|
||
[
|
||
f"- 数量:{plan.get('total_count', '')} 条;轮次:{plan.get('turn_mix', '')}",
|
||
(
|
||
'- 范围:'
|
||
f"覆盖:{self._short_review_text(plan.get('coverage'), limit=70)};"
|
||
f"排除:{self._short_review_text(plan.get('exclusions'), limit=50)}"
|
||
),
|
||
f"- 输出:{plan.get('output_path', '')}",
|
||
f"- 内部:plan_id `{plan.get('plan_id', '')}`,revision `{plan.get('revision', '')}`",
|
||
'',
|
||
'如果目标、数量和路径都可以,就回“确认,开始生成”;想调整就直接说哪里改。',
|
||
]
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
def _format_review_targets(self, payload: dict[str, object]) -> str:
|
||
targets = payload.get('target_definitions')
|
||
if isinstance(targets, list) and targets:
|
||
parts: list[str] = []
|
||
for item in targets:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get('name') or '').strip()
|
||
target = str(item.get('target') or '').strip()
|
||
if name and target:
|
||
parts.append(f'{name} -> `{target}`')
|
||
elif target:
|
||
parts.append(f'`{target}`')
|
||
return ';'.join(parts)
|
||
target = payload.get('target')
|
||
return f'`{target}`' if isinstance(target, str) and target.strip() else ''
|
||
|
||
def _short_review_text(self, value: object, *, limit: int = 120) -> str:
|
||
text = ' '.join(str(value or '').split())
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 1].rstrip() + '…'
|
||
|
||
def _compact_prefix_count(self, session: AgentSessionState) -> int:
|
||
prefix_count = 0
|
||
for message in session.messages:
|
||
if prefix_count == 0 and message.role == 'system':
|
||
prefix_count += 1
|
||
continue
|
||
if (
|
||
prefix_count == 1
|
||
and message.role == 'user'
|
||
and message.content.startswith('<system-reminder>')
|
||
):
|
||
prefix_count += 1
|
||
continue
|
||
break
|
||
return prefix_count
|
||
|
||
def _message_can_be_snipped(self, message) -> bool:
|
||
if message.metadata.get('kind') in {
|
||
'compact_boundary',
|
||
'snipped_message',
|
||
'file_history_replay',
|
||
}:
|
||
return False
|
||
if message.role == 'tool':
|
||
return True
|
||
if message.role == 'assistant' and (message.tool_calls or len(message.content) > 600):
|
||
return True
|
||
if (
|
||
message.role == 'user'
|
||
and message.metadata.get('kind') in {'continuation_request', 'file_history_replay'}
|
||
):
|
||
return True
|
||
return False
|
||
|
||
def _build_snipped_message_content(self, message) -> str:
|
||
preview = ' '.join(message.content.split())
|
||
if len(preview) > 120:
|
||
preview = preview[:117] + '...'
|
||
if message.role == 'tool':
|
||
label = f'tool result ({message.name or "tool"})'
|
||
elif message.role == 'assistant':
|
||
label = 'assistant message with tool calls'
|
||
else:
|
||
label = message.role
|
||
return (
|
||
'<system-reminder>\n'
|
||
f'Older {label} was snipped to save context.\n'
|
||
f'Message id: {message.message_id or "(none)"}\n'
|
||
f'Preview: {preview or "(empty)"}\n'
|
||
'</system-reminder>'
|
||
)
|
||
|
||
def _build_compact_boundary_message(
|
||
self,
|
||
messages,
|
||
*,
|
||
turn_index: int,
|
||
estimated_tokens_before: int,
|
||
estimated_tokens_removed: int,
|
||
preserved_tail_count: int,
|
||
preserved_tail,
|
||
):
|
||
summary_lines = [
|
||
'<system-reminder>',
|
||
'Earlier conversation history was compacted to keep the session within the context budget.',
|
||
'',
|
||
'Compacted summary:',
|
||
]
|
||
remaining = 24
|
||
for message in messages:
|
||
if remaining <= 0:
|
||
break
|
||
label = message.role
|
||
if message.role == 'tool' and message.name:
|
||
label = f'tool:{message.name}'
|
||
snippet = ' '.join(message.content.split())
|
||
if len(snippet) > 160:
|
||
snippet = snippet[:157] + '...'
|
||
if not snippet:
|
||
snippet = '(empty)'
|
||
summary_lines.append(f'- {label}: {snippet}')
|
||
remaining -= 1
|
||
if len(messages) > 24:
|
||
summary_lines.append(f'- ... plus {len(messages) - 24} older messages')
|
||
summary_lines.extend(
|
||
[
|
||
'',
|
||
'Keep using the preserved recent tail as the active working set.',
|
||
'</system-reminder>',
|
||
]
|
||
)
|
||
from .agent_session import AgentMessage
|
||
|
||
nested_compaction_count = sum(
|
||
1 for message in messages if message.metadata.get('kind') == 'compact_boundary'
|
||
)
|
||
prior_depths = [
|
||
int(message.metadata.get('compaction_depth', 0))
|
||
for message in messages
|
||
if isinstance(message.metadata.get('compaction_depth', 0), int)
|
||
]
|
||
compaction_depth = (max(prior_depths) if prior_depths else 0) + 1
|
||
compacted_kinds: dict[str, int] = {}
|
||
source_mutation_totals: dict[str, int] = {}
|
||
compacted_lineage_ids: list[str] = []
|
||
preserved_tail_lineage_ids = [
|
||
lineage_id
|
||
for lineage_id in (
|
||
message.metadata.get('lineage_id') for message in preserved_tail
|
||
)
|
||
if isinstance(lineage_id, str) and lineage_id
|
||
]
|
||
max_source_revision = 0
|
||
max_source_mutation_serial = 0
|
||
compacted_revision_total = 0
|
||
for message in messages:
|
||
kind = message.metadata.get('kind')
|
||
label = str(kind) if isinstance(kind, str) and kind else message.role
|
||
compacted_kinds[label] = compacted_kinds.get(label, 0) + 1
|
||
lineage_id = message.metadata.get('lineage_id')
|
||
if isinstance(lineage_id, str) and lineage_id:
|
||
compacted_lineage_ids.append(lineage_id)
|
||
revision = message.metadata.get('revision')
|
||
if isinstance(revision, int) and not isinstance(revision, bool):
|
||
max_source_revision = max(max_source_revision, revision)
|
||
compacted_revision_total += revision
|
||
max_mutation_serial = message.metadata.get('max_mutation_serial')
|
||
if isinstance(max_mutation_serial, int) and not isinstance(max_mutation_serial, bool):
|
||
max_source_mutation_serial = max(
|
||
max_source_mutation_serial,
|
||
max_mutation_serial,
|
||
)
|
||
mutation_totals = message.metadata.get('mutation_totals')
|
||
if isinstance(mutation_totals, dict):
|
||
for mutation_kind, count in mutation_totals.items():
|
||
if (
|
||
not isinstance(mutation_kind, str)
|
||
or not mutation_kind
|
||
or isinstance(count, bool)
|
||
or not isinstance(count, int)
|
||
or count <= 0
|
||
):
|
||
continue
|
||
source_mutation_totals[mutation_kind] = (
|
||
source_mutation_totals.get(mutation_kind, 0) + count
|
||
)
|
||
|
||
compact_boundary_id = f'compact_boundary_{turn_index}_{len(messages)}'
|
||
|
||
return AgentMessage(
|
||
role='system',
|
||
content='\n'.join(summary_lines),
|
||
message_id=compact_boundary_id,
|
||
metadata={
|
||
'kind': 'compact_boundary',
|
||
'lineage_id': compact_boundary_id,
|
||
'revision': 0,
|
||
'revision_count': 1,
|
||
'message_role': 'system',
|
||
'turn_index': turn_index,
|
||
'compacted_message_count': len(messages),
|
||
'estimated_tokens_before': estimated_tokens_before,
|
||
'estimated_tokens_removed': estimated_tokens_removed,
|
||
'preserved_tail_count': preserved_tail_count,
|
||
'preserved_tail_ids': [
|
||
message.message_id for message in preserved_tail if message.message_id
|
||
],
|
||
'compaction_depth': compaction_depth,
|
||
'nested_compaction_count': nested_compaction_count,
|
||
'compacted_kinds': compacted_kinds,
|
||
'compacted_lineage_ids': compacted_lineage_ids,
|
||
'preserved_tail_lineage_ids': preserved_tail_lineage_ids,
|
||
'max_source_revision': max_source_revision,
|
||
'max_source_mutation_serial': max_source_mutation_serial,
|
||
'source_mutation_totals': source_mutation_totals,
|
||
'compacted_revision_total': compacted_revision_total,
|
||
'compacted_message_ids': [
|
||
message.message_id for message in messages if message.message_id
|
||
],
|
||
},
|
||
)
|
||
|
||
def _is_prompt_too_long_error(self, exc: Exception) -> bool:
|
||
text = str(exc).lower()
|
||
patterns = (
|
||
'prompt is too long',
|
||
'maximum context length',
|
||
'context length exceeded',
|
||
'too many tokens',
|
||
'input too long',
|
||
'context window',
|
||
)
|
||
return any(pattern in text for pattern in patterns)
|
||
|
||
def _execute_skill(
|
||
self,
|
||
arguments: dict[str, object],
|
||
*,
|
||
active_skill_names: set[str] | None = None,
|
||
duplicate_skill_counts: dict[str, int] | None = None,
|
||
) -> ToolExecutionResult:
|
||
"""Execute a skill through the Skill tool.
|
||
|
||
Checks bundled skills first, then falls back to slash commands.
|
||
"""
|
||
from .agent_slash_commands import find_slash_command, get_slash_command_specs
|
||
from .bundled_skills import find_bundled_skill, get_bundled_skills
|
||
|
||
skill_name = arguments.get('skill')
|
||
if not isinstance(skill_name, str) or not skill_name.strip():
|
||
return ToolExecutionResult(
|
||
name='Skill',
|
||
ok=False,
|
||
content='skill must be a non-empty string',
|
||
)
|
||
|
||
# Normalize: strip leading '/' if present
|
||
skill_name = skill_name.strip().lstrip('/')
|
||
args = arguments.get('args', '')
|
||
if not isinstance(args, str):
|
||
args = str(args) if args is not None else ''
|
||
|
||
# 1. Check bundled skills first
|
||
bundled = find_bundled_skill(skill_name, cwd=self.runtime_config.cwd)
|
||
if bundled is not None:
|
||
skill_key = bundled.name.strip().lower()
|
||
if active_skill_names is not None and skill_key in active_skill_names and not bundled.repeatable:
|
||
duplicate_count = 1
|
||
if duplicate_skill_counts is not None:
|
||
duplicate_count = duplicate_skill_counts.get(skill_key, 0) + 1
|
||
duplicate_skill_counts[skill_key] = duplicate_count
|
||
stop_agent = duplicate_count >= 2
|
||
return ToolExecutionResult(
|
||
name='Skill',
|
||
ok=True,
|
||
content=(
|
||
f'Skill "{bundled.name}" 已在本轮激活。'
|
||
'请直接依据前面注入的 Skill 指南继续执行,不要重复调用 Skill 工具。'
|
||
if not stop_agent
|
||
else (
|
||
f'Skill "{bundled.name}" 已在本轮重复调用 {duplicate_count} 次。'
|
||
'运行时已暂停,避免继续空转。'
|
||
)
|
||
),
|
||
metadata={
|
||
'action': 'skill_duplicate',
|
||
'skill_name': bundled.name,
|
||
'source': bundled.source,
|
||
'skill_path': bundled.path,
|
||
'should_query': True,
|
||
'duplicate': True,
|
||
'duplicate_count': duplicate_count,
|
||
**(
|
||
{
|
||
'stop_agent': True,
|
||
'stop_reason': 'duplicate_skill_loop',
|
||
}
|
||
if stop_agent
|
||
else {}
|
||
),
|
||
},
|
||
)
|
||
if active_skill_names is not None:
|
||
active_skill_names.add(skill_key)
|
||
prompt = bundled.get_prompt(self, args.strip())
|
||
return ToolExecutionResult(
|
||
name='Skill',
|
||
ok=True,
|
||
content=prompt,
|
||
metadata={
|
||
'action': 'skill',
|
||
'skill_name': bundled.name,
|
||
'source': bundled.source,
|
||
'skill_path': bundled.path,
|
||
'should_query': True,
|
||
},
|
||
)
|
||
|
||
# 2. Fall back to slash commands
|
||
spec = find_slash_command(skill_name)
|
||
if spec is None:
|
||
available_cmds = sorted(
|
||
name
|
||
for s in get_slash_command_specs()
|
||
for name in s.names
|
||
)
|
||
available_skills = [sk.name for sk in get_bundled_skills(self.runtime_config.cwd)]
|
||
all_available = sorted(set(available_cmds + available_skills))
|
||
return ToolExecutionResult(
|
||
name='Skill',
|
||
ok=False,
|
||
content=(
|
||
f'Unknown skill: {skill_name}. '
|
||
f'Available skills: {", ".join(all_available[:30])}'
|
||
),
|
||
metadata={'action': 'skill_not_found', 'skill_name': skill_name},
|
||
)
|
||
|
||
# Invoke the slash command handler
|
||
input_text = f'/{skill_name} {args}'.strip()
|
||
result = spec.handler(self, args.strip(), input_text)
|
||
|
||
if result.output:
|
||
content = result.output
|
||
elif result.prompt:
|
||
content = result.prompt
|
||
else:
|
||
content = f'Skill /{skill_name} completed.'
|
||
|
||
return ToolExecutionResult(
|
||
name='Skill',
|
||
ok=True,
|
||
content=content,
|
||
metadata={
|
||
'action': 'skill',
|
||
'skill_name': skill_name,
|
||
'command_name': spec.names[0],
|
||
'handled': result.handled,
|
||
'should_query': result.should_query,
|
||
},
|
||
)
|
||
|
||
def _resolve_agent_definition(
|
||
self,
|
||
arguments: dict[str, object],
|
||
) -> AgentDefinition:
|
||
"""Resolve the agent definition from subagent_type or default to general-purpose."""
|
||
subagent_type = arguments.get('subagent_type')
|
||
if isinstance(subagent_type, str) and subagent_type:
|
||
agent_def = find_agent_definition(self.runtime_config.cwd, subagent_type)
|
||
if agent_def is not None:
|
||
return agent_def
|
||
return GENERAL_PURPOSE_AGENT
|
||
|
||
def _resolve_child_model_config(
|
||
self,
|
||
arguments: dict[str, object],
|
||
agent_def: AgentDefinition,
|
||
) -> ModelConfig:
|
||
"""Resolve model config for a child agent based on explicit override or agent definition."""
|
||
model_override = arguments.get('model')
|
||
agent_model = agent_def.model
|
||
|
||
# Explicit model param in arguments takes priority
|
||
if isinstance(model_override, str) and model_override.strip():
|
||
resolved_model = self._resolve_child_model_name(model_override.strip())
|
||
return replace(self.model_config, model=resolved_model)
|
||
|
||
# Agent definition model
|
||
if agent_model and agent_model != 'inherit':
|
||
resolved_model = self._resolve_child_model_name(agent_model)
|
||
return replace(self.model_config, model=resolved_model)
|
||
|
||
return self.model_config
|
||
|
||
def _resolve_child_model_name(self, requested_model: str) -> str:
|
||
"""Map Claude-style child model aliases only when the parent model is Claude-like."""
|
||
|
||
normalized = requested_model.strip()
|
||
if normalized in {'haiku', 'sonnet', 'opus'} and not self._is_claude_model(self.model_config.model):
|
||
return self.model_config.model
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _is_claude_model(model: str) -> bool:
|
||
lowered = model.lower()
|
||
return lowered.startswith('claude') or '/claude' in lowered or 'anthropic' in lowered
|
||
|
||
def _filter_tools_for_agent(
|
||
self,
|
||
agent_def: AgentDefinition,
|
||
) -> dict[str, AgentTool]:
|
||
"""Build the tool registry for a child agent based on its definition."""
|
||
# Start from parent tools, remove Agent/delegate_agent to prevent recursive spawning
|
||
base_tools = {
|
||
name: tool
|
||
for name, tool in self.tool_registry.items()
|
||
if name not in ('delegate_agent', 'Agent')
|
||
}
|
||
|
||
# Apply agent-specific tool allow-list
|
||
if agent_def.tools is not None:
|
||
allowed = set(agent_def.tools)
|
||
base_tools = {
|
||
name: tool
|
||
for name, tool in base_tools.items()
|
||
if name in allowed
|
||
}
|
||
|
||
# Apply agent-specific disallowed tools
|
||
if agent_def.disallowed_tools:
|
||
denied = set(agent_def.disallowed_tools)
|
||
base_tools = {
|
||
name: tool
|
||
for name, tool in base_tools.items()
|
||
if name not in denied
|
||
}
|
||
|
||
# Apply universal agent disallowed tools
|
||
base_tools = {
|
||
name: tool
|
||
for name, tool in base_tools.items()
|
||
if name not in ALL_AGENT_DISALLOWED_TOOLS
|
||
}
|
||
|
||
return base_tools
|
||
|
||
def _execute_delegate_agent(
|
||
self,
|
||
arguments: dict[str, object],
|
||
) -> ToolExecutionResult:
|
||
tool_name = 'Agent'
|
||
agent_def = self._resolve_agent_definition(arguments)
|
||
max_turns = arguments.get('max_turns')
|
||
if max_turns is not None and (isinstance(max_turns, bool) or not isinstance(max_turns, int) or max_turns < 1):
|
||
return ToolExecutionResult(
|
||
name=tool_name,
|
||
ok=False,
|
||
content='max_turns must be an integer >= 1',
|
||
)
|
||
subtasks = self._normalize_delegate_subtasks(arguments)
|
||
if not subtasks:
|
||
return ToolExecutionResult(
|
||
name=tool_name,
|
||
ok=False,
|
||
content='prompt must be a non-empty string or subtasks must contain at least one prompt',
|
||
)
|
||
|
||
# Resolve child permissions — read-only agents get no write/shell
|
||
if agent_def.disallowed_tools and (
|
||
'edit_file' in agent_def.disallowed_tools
|
||
or 'write_file' in agent_def.disallowed_tools
|
||
):
|
||
# Read-only agent (Explore, Plan, verification)
|
||
child_permissions = AgentPermissions(
|
||
allow_file_write=False,
|
||
allow_shell_commands=self.runtime_config.permissions.allow_shell_commands,
|
||
allow_destructive_shell_commands=False,
|
||
)
|
||
else:
|
||
child_permissions = AgentPermissions(
|
||
allow_file_write=(
|
||
self.runtime_config.permissions.allow_file_write
|
||
and bool(arguments.get('allow_write', False))
|
||
),
|
||
allow_shell_commands=(
|
||
self.runtime_config.permissions.allow_shell_commands
|
||
and arguments.get('allow_shell', True) is not False
|
||
),
|
||
allow_destructive_shell_commands=False,
|
||
)
|
||
|
||
# Resolve max_turns — agent definition or explicit param
|
||
effective_max_turns = max_turns or agent_def.max_turns or min(self.runtime_config.max_turns, 50)
|
||
|
||
child_runtime_config = replace(
|
||
self.runtime_config,
|
||
max_turns=effective_max_turns,
|
||
permissions=child_permissions,
|
||
auto_compact_threshold_tokens=self.runtime_config.auto_compact_threshold_tokens,
|
||
)
|
||
|
||
child_model_config = self._resolve_child_model_config(arguments, agent_def)
|
||
child_tools = self._filter_tools_for_agent(agent_def)
|
||
include_parent_context = bool(arguments.get('include_parent_context', True))
|
||
continue_on_error = bool(arguments.get('continue_on_error', True))
|
||
max_failures = arguments.get('max_failures')
|
||
if isinstance(max_failures, bool) or (max_failures is not None and not isinstance(max_failures, int)):
|
||
max_failures = None
|
||
if isinstance(max_failures, int) and max_failures < 0:
|
||
max_failures = None
|
||
strategy = self._normalize_delegate_strategy(arguments.get('strategy'))
|
||
child_summaries: list[dict[str, object]] = []
|
||
child_session_ids: list[str] = []
|
||
prior_results: list[dict[str, str]] = []
|
||
completed_labels: set[str] = set()
|
||
failed_labels: set[str] = set()
|
||
delegate_preflight_messages = (
|
||
self.plugin_runtime.before_delegate_injections()
|
||
if self.plugin_runtime is not None
|
||
else ()
|
||
)
|
||
delegate_after_messages: tuple[str, ...] = ()
|
||
group_id: str | None = None
|
||
if self.agent_manager is not None and len(subtasks) > 1:
|
||
group_id = self.agent_manager.start_group(
|
||
label=str(arguments.get('label') or 'delegated_group'),
|
||
parent_agent_id=self.managed_agent_id,
|
||
strategy=strategy,
|
||
)
|
||
planned_batches = self._plan_delegate_batches(subtasks, strategy)
|
||
batch_summaries: list[dict[str, object]] = []
|
||
failed_children = 0
|
||
dependency_skips = 0
|
||
child_result = None
|
||
stop_processing = False
|
||
max_concurrency = int(arguments.get('max_concurrency', 4))
|
||
use_parallel = strategy == 'topological' and max_concurrency > 1
|
||
for batch_index, batch in enumerate(planned_batches, start=1):
|
||
if stop_processing:
|
||
break
|
||
batch_completed = 0
|
||
batch_failed = 0
|
||
batch_skipped = 0
|
||
batch_labels: list[str] = []
|
||
|
||
runnable_subtasks: list[dict[str, object]] = []
|
||
for subtask in batch:
|
||
index = int(subtask.get('_delegate_index', len(child_summaries) + 1))
|
||
subtask_label = str(subtask.get('label') or f'subtask_{index}')
|
||
batch_labels.append(subtask_label)
|
||
dependencies = tuple(
|
||
item
|
||
for item in subtask.get('depends_on', ())
|
||
if isinstance(item, str) and item
|
||
)
|
||
unmet_dependencies = [
|
||
dependency
|
||
for dependency in dependencies
|
||
if dependency not in completed_labels
|
||
]
|
||
blocked_dependencies = [
|
||
dependency
|
||
for dependency in dependencies
|
||
if dependency in failed_labels
|
||
]
|
||
if unmet_dependencies:
|
||
skip_reason = (
|
||
'skipped_dependency'
|
||
if blocked_dependencies
|
||
else 'pending_dependency'
|
||
)
|
||
child_result = AgentRunResult(
|
||
final_output=(
|
||
'Skipped delegated subtask because dependencies were not satisfied: '
|
||
+ ', '.join(unmet_dependencies)
|
||
),
|
||
turns=0,
|
||
tool_calls=0,
|
||
transcript=(),
|
||
stop_reason=skip_reason,
|
||
)
|
||
summary = {
|
||
'index': index,
|
||
'label': subtask_label,
|
||
'session_id': '',
|
||
'turns': child_result.turns,
|
||
'tool_calls': child_result.tool_calls,
|
||
'stop_reason': skip_reason,
|
||
'output_preview': self._preview_text(child_result.final_output, 220),
|
||
'resume_used': False,
|
||
'resumed_from_session_id': '',
|
||
'depends_on': list(dependencies),
|
||
'batch_index': batch_index,
|
||
}
|
||
child_summaries.append(summary)
|
||
failed_children += 1
|
||
batch_failed += 1
|
||
batch_skipped += 1
|
||
dependency_skips += 1
|
||
failed_labels.add(subtask_label)
|
||
if isinstance(max_failures, int) and failed_children > max_failures:
|
||
stop_processing = True
|
||
break
|
||
if not continue_on_error:
|
||
stop_processing = True
|
||
break
|
||
continue
|
||
runnable_subtasks.append(subtask)
|
||
|
||
if stop_processing:
|
||
break
|
||
|
||
if use_parallel and len(runnable_subtasks) > 1:
|
||
batch_results = self._run_batch_parallel(
|
||
runnable_subtasks,
|
||
agent_def=agent_def,
|
||
child_model_config=child_model_config,
|
||
child_runtime_config=child_runtime_config,
|
||
child_tools=child_tools,
|
||
group_id=group_id,
|
||
batch_index=batch_index,
|
||
include_parent_context=include_parent_context,
|
||
prior_results=prior_results,
|
||
delegate_preflight_messages=delegate_preflight_messages,
|
||
max_concurrency=max_concurrency,
|
||
)
|
||
for br in batch_results:
|
||
child_result = br['result']
|
||
summary = br['summary']
|
||
child_summaries.append(summary)
|
||
if child_result.session_id:
|
||
child_session_ids.append(child_result.session_id)
|
||
prior_results.append(
|
||
{
|
||
'label': summary['label'],
|
||
'output_preview': str(summary['output_preview']),
|
||
}
|
||
)
|
||
if child_result.stop_reason in {'backend_error', 'budget_exceeded'}:
|
||
failed_children += 1
|
||
batch_failed += 1
|
||
failed_labels.add(str(summary['label']))
|
||
else:
|
||
batch_completed += 1
|
||
completed_labels.add(str(summary['label']))
|
||
if isinstance(max_failures, int) and failed_children > max_failures:
|
||
stop_processing = True
|
||
else:
|
||
for subtask in runnable_subtasks:
|
||
result_info = self._run_single_subtask(
|
||
subtask,
|
||
agent_def=agent_def,
|
||
child_model_config=child_model_config,
|
||
child_runtime_config=child_runtime_config,
|
||
child_tools=child_tools,
|
||
group_id=group_id,
|
||
batch_index=batch_index,
|
||
include_parent_context=include_parent_context,
|
||
prior_results=prior_results,
|
||
delegate_preflight_messages=delegate_preflight_messages,
|
||
)
|
||
child_result = result_info['result']
|
||
summary = result_info['summary']
|
||
child_summaries.append(summary)
|
||
if child_result.session_id:
|
||
child_session_ids.append(child_result.session_id)
|
||
prior_results.append(
|
||
{
|
||
'label': summary['label'],
|
||
'output_preview': str(summary['output_preview']),
|
||
}
|
||
)
|
||
if child_result.stop_reason in {'backend_error', 'budget_exceeded'}:
|
||
failed_children += 1
|
||
batch_failed += 1
|
||
failed_labels.add(str(summary['label']))
|
||
if isinstance(max_failures, int) and failed_children > max_failures:
|
||
stop_processing = True
|
||
break
|
||
if not continue_on_error:
|
||
stop_processing = True
|
||
break
|
||
else:
|
||
batch_completed += 1
|
||
completed_labels.add(str(summary['label']))
|
||
batch_status = 'completed'
|
||
if batch_failed and batch_completed:
|
||
batch_status = 'partial'
|
||
elif batch_failed:
|
||
batch_status = 'failed'
|
||
batch_summaries.append(
|
||
{
|
||
'batch_index': batch_index,
|
||
'labels': batch_labels,
|
||
'completed_children': batch_completed,
|
||
'failed_children': batch_failed,
|
||
'skipped_children': batch_skipped,
|
||
'status': batch_status,
|
||
}
|
||
)
|
||
assert child_result is not None
|
||
completed_children = len(child_summaries) - failed_children
|
||
resumed_children = sum(
|
||
1 for summary in child_summaries if summary.get('resume_used')
|
||
)
|
||
group_status = 'completed'
|
||
if failed_children and completed_children:
|
||
group_status = 'partial'
|
||
elif failed_children:
|
||
group_status = 'failed'
|
||
delegate_after_messages = (
|
||
self.plugin_runtime.after_delegate_injections()
|
||
if self.plugin_runtime is not None
|
||
else ()
|
||
)
|
||
if group_id is not None and self.agent_manager is not None:
|
||
self.agent_manager.finish_group(
|
||
group_id,
|
||
status=group_status,
|
||
completed_children=completed_children,
|
||
failed_children=failed_children,
|
||
batch_count=len(batch_summaries),
|
||
max_batch_size=max((len(batch['labels']) for batch in batch_summaries), default=0),
|
||
dependency_skips=dependency_skips,
|
||
)
|
||
summary_lines = [
|
||
(
|
||
'Delegated agent completed the subtask.'
|
||
if len(child_summaries) == 1
|
||
else f'Delegated agent completed {len(child_summaries)} sequential subtasks.'
|
||
),
|
||
]
|
||
if group_id is not None:
|
||
summary_lines.append(f'group_id={group_id}')
|
||
summary_lines.append(f'group_status={group_status}')
|
||
summary_lines.append(f'resumed_children={resumed_children}')
|
||
summary_lines.append(f'strategy={strategy}')
|
||
summary_lines.append(f'batch_count={len(batch_summaries)}')
|
||
summary_lines.append(f'dependency_skips={dependency_skips}')
|
||
summary_lines.append('')
|
||
if delegate_preflight_messages:
|
||
summary_lines.append('Plugin delegate preflight:')
|
||
summary_lines.extend(f'- {message}' for message in delegate_preflight_messages)
|
||
summary_lines.append('')
|
||
for batch in batch_summaries:
|
||
summary_lines.append(
|
||
f"[batch {batch['batch_index']}] status={batch['status']} "
|
||
f"labels={','.join(batch['labels']) or '(none)'} "
|
||
f"completed={batch['completed_children']} failed={batch['failed_children']} "
|
||
f"skipped={batch['skipped_children']}"
|
||
)
|
||
if batch_summaries:
|
||
summary_lines.append('')
|
||
for summary in child_summaries:
|
||
summary_lines.extend(
|
||
[
|
||
f"[{summary['label']}]",
|
||
f"batch_index={summary['batch_index']}",
|
||
f"session_id={summary['session_id']}",
|
||
f"turns={summary['turns']}",
|
||
f"tool_calls={summary['tool_calls']}",
|
||
f"stop_reason={summary['stop_reason']}",
|
||
f"resume_used={summary['resume_used']}",
|
||
f"resumed_from_session_id={summary['resumed_from_session_id']}",
|
||
f"depends_on={','.join(summary.get('depends_on', [])) or '(none)'}",
|
||
f"output_preview={summary['output_preview']}",
|
||
'',
|
||
]
|
||
)
|
||
if delegate_after_messages:
|
||
summary_lines.append('Plugin delegate completion:')
|
||
summary_lines.extend(f'- {message}' for message in delegate_after_messages)
|
||
summary_lines.append('')
|
||
summary_lines.append('Final delegated output:')
|
||
summary_lines.append(child_result.final_output)
|
||
return ToolExecutionResult(
|
||
name=tool_name,
|
||
ok=True,
|
||
content='\n'.join(summary_lines).strip(),
|
||
metadata={
|
||
'action': 'Agent',
|
||
'subagent_type': agent_def.agent_type,
|
||
'child_session_id': child_result.session_id,
|
||
'child_session_ids': child_session_ids,
|
||
'child_turns': child_result.turns,
|
||
'child_tool_calls': child_result.tool_calls,
|
||
'child_stop_reason': child_result.stop_reason,
|
||
'child_results': child_summaries,
|
||
'subtask_count': len(child_summaries),
|
||
'group_id': group_id,
|
||
'group_status': group_status,
|
||
'failed_children': failed_children,
|
||
'completed_children': completed_children,
|
||
'resumed_children': resumed_children,
|
||
'strategy': strategy,
|
||
'max_failures': max_failures,
|
||
'delegate_batches': batch_summaries,
|
||
'dependency_skips': dependency_skips,
|
||
'plugin_delegate_preflight_messages': list(delegate_preflight_messages),
|
||
'plugin_delegate_after_messages': list(delegate_after_messages),
|
||
},
|
||
)
|
||
|
||
def _normalize_delegate_subtasks(
|
||
self,
|
||
arguments: dict[str, object],
|
||
) -> list[dict[str, object]]:
|
||
subtasks: list[dict[str, object]] = []
|
||
raw_subtasks = arguments.get('subtasks')
|
||
if isinstance(raw_subtasks, list):
|
||
for index, item in enumerate(raw_subtasks, start=1):
|
||
if isinstance(item, str) and item.strip():
|
||
subtasks.append(
|
||
{
|
||
'prompt': item.strip(),
|
||
'label': f'subtask_{index}',
|
||
'_delegate_index': index,
|
||
}
|
||
)
|
||
continue
|
||
if isinstance(item, dict):
|
||
prompt = item.get('prompt')
|
||
if not isinstance(prompt, str) or not prompt.strip():
|
||
continue
|
||
label = item.get('label')
|
||
max_turns = item.get('max_turns')
|
||
task: dict[str, object] = {
|
||
'prompt': prompt.strip(),
|
||
'label': label if isinstance(label, str) and label.strip() else f'subtask_{index}',
|
||
}
|
||
resume_session_id = item.get('resume_session_id')
|
||
if resume_session_id is None:
|
||
resume_session_id = item.get('session_id')
|
||
if isinstance(resume_session_id, str) and resume_session_id.strip():
|
||
task['resume_session_id'] = resume_session_id.strip()
|
||
depends_on = item.get('depends_on')
|
||
if isinstance(depends_on, list):
|
||
task['depends_on'] = tuple(
|
||
dependency.strip()
|
||
for dependency in depends_on
|
||
if isinstance(dependency, str) and dependency.strip()
|
||
)
|
||
if isinstance(max_turns, int) and not isinstance(max_turns, bool) and max_turns > 0:
|
||
task['max_turns'] = max_turns
|
||
task['_delegate_index'] = index
|
||
subtasks.append(task)
|
||
prompt = arguments.get('prompt')
|
||
if isinstance(prompt, str) and prompt.strip():
|
||
if not subtasks:
|
||
task: dict[str, object] = {'prompt': prompt.strip(), 'label': 'subtask_1'}
|
||
resume_session_id = arguments.get('resume_session_id')
|
||
if resume_session_id is None:
|
||
resume_session_id = arguments.get('session_id')
|
||
if isinstance(resume_session_id, str) and resume_session_id.strip():
|
||
task['resume_session_id'] = resume_session_id.strip()
|
||
task['_delegate_index'] = 1
|
||
subtasks.append(task)
|
||
return [
|
||
{
|
||
**task,
|
||
'_delegate_index': int(task.get('_delegate_index', index)),
|
||
}
|
||
for index, task in enumerate(subtasks[:8], start=1)
|
||
]
|
||
|
||
def _run_single_subtask(
|
||
self,
|
||
subtask: dict[str, object],
|
||
*,
|
||
agent_def: 'AgentDefinition',
|
||
child_model_config: 'ModelConfig',
|
||
child_runtime_config: 'AgentRuntimeConfig',
|
||
child_tools: dict[str, 'AgentTool'],
|
||
group_id: str | None,
|
||
batch_index: int,
|
||
include_parent_context: bool,
|
||
prior_results: list[dict[str, str]],
|
||
delegate_preflight_messages: tuple[str, ...],
|
||
) -> dict[str, object]:
|
||
"""Run a single subtask and return {'result': AgentRunResult, 'summary': dict}."""
|
||
index = int(subtask.get('_delegate_index', 0))
|
||
subtask_label = str(subtask.get('label') or f'subtask_{index}')
|
||
dependencies = tuple(
|
||
item for item in subtask.get('depends_on', ()) if isinstance(item, str) and item
|
||
)
|
||
|
||
child_system_prompt = agent_def.system_prompt or self.custom_system_prompt
|
||
child_override_prompt = None
|
||
if agent_def.system_prompt:
|
||
child_override_prompt = agent_def.system_prompt
|
||
else:
|
||
child_override_prompt = self.override_system_prompt
|
||
|
||
child_append_prompt = self.append_system_prompt
|
||
if agent_def.critical_system_reminder:
|
||
reminder = f'\n\n<system-reminder>\n{agent_def.critical_system_reminder}\n</system-reminder>'
|
||
child_append_prompt = (child_append_prompt or '') + reminder
|
||
|
||
child_agent = LocalCodingAgent(
|
||
model_config=child_model_config,
|
||
runtime_config=replace(
|
||
child_runtime_config,
|
||
max_turns=subtask.get('max_turns', child_runtime_config.max_turns),
|
||
disable_claude_md_discovery=agent_def.omit_claude_md,
|
||
),
|
||
custom_system_prompt=child_system_prompt if not child_override_prompt else None,
|
||
append_system_prompt=child_append_prompt,
|
||
override_system_prompt=child_override_prompt,
|
||
tool_registry=child_tools,
|
||
agent_manager=self.agent_manager,
|
||
parent_agent_id=self.managed_agent_id,
|
||
managed_group_id=group_id,
|
||
managed_child_index=index,
|
||
managed_label=subtask_label,
|
||
session_metadata={
|
||
'visibility': 'child',
|
||
'parent_session_id': self.active_session_id or '',
|
||
'subagent_type': agent_def.agent_type,
|
||
'delegate_label': subtask_label,
|
||
'delegate_index': index,
|
||
'delegate_batch_index': batch_index,
|
||
**({'delegate_group_id': group_id} if group_id is not None else {}),
|
||
},
|
||
)
|
||
if self.tool_context.jupyter_runtime is not None:
|
||
child_agent.tool_context = replace(
|
||
child_agent.tool_context,
|
||
jupyter_runtime=self.tool_context.jupyter_runtime,
|
||
)
|
||
if group_id is not None and child_agent.managed_agent_id is not None:
|
||
self.agent_manager.register_group_child(
|
||
group_id,
|
||
child_agent.managed_agent_id,
|
||
child_index=index,
|
||
)
|
||
resume_session_id = subtask.get('resume_session_id')
|
||
child_prompt = str(subtask['prompt'])
|
||
if agent_def.initial_prompt and not (
|
||
isinstance(resume_session_id, str) and resume_session_id
|
||
):
|
||
child_prompt = f'{agent_def.initial_prompt.strip()}\n\n{child_prompt}'.strip()
|
||
if delegate_preflight_messages:
|
||
child_prompt = self._prepend_plugin_delegate_context(
|
||
child_prompt, delegate_preflight_messages,
|
||
)
|
||
if include_parent_context and prior_results:
|
||
child_prompt = self._prepend_delegate_context(child_prompt, prior_results)
|
||
resume_used = False
|
||
if isinstance(resume_session_id, str) and resume_session_id:
|
||
try:
|
||
stored_child_session = load_agent_session(
|
||
resume_session_id,
|
||
directory=child_runtime_config.session_directory,
|
||
)
|
||
except OSError:
|
||
child_result = AgentRunResult(
|
||
final_output=f'Unable to load delegated session {resume_session_id}.',
|
||
turns=0, tool_calls=0, transcript=(),
|
||
stop_reason='resume_load_error', session_id=resume_session_id,
|
||
)
|
||
return {
|
||
'result': child_result,
|
||
'summary': {
|
||
'index': index, 'label': subtask_label,
|
||
'session_id': resume_session_id,
|
||
'turns': 0, 'tool_calls': 0,
|
||
'stop_reason': 'resume_load_error',
|
||
'output_preview': self._preview_text(child_result.final_output, 220),
|
||
'resume_used': True, 'resumed_from_session_id': resume_session_id,
|
||
'depends_on': list(dependencies), 'batch_index': batch_index,
|
||
},
|
||
}
|
||
child_result = child_agent.resume(child_prompt, stored_child_session)
|
||
_log_child_skill_calls(child_result, child_agent, subtask_label)
|
||
resume_used = True
|
||
else:
|
||
child_result = child_agent.run(child_prompt)
|
||
_log_child_skill_calls(child_result, child_agent, subtask_label)
|
||
if group_id is not None and child_agent.managed_agent_id is not None:
|
||
self.agent_manager.register_group_child(
|
||
group_id, child_agent.managed_agent_id, child_index=index,
|
||
)
|
||
summary = {
|
||
'index': index, 'label': subtask_label,
|
||
'session_id': child_result.session_id or '',
|
||
'turns': child_result.turns, 'tool_calls': child_result.tool_calls,
|
||
'stop_reason': child_result.stop_reason or 'stop',
|
||
'output_preview': self._preview_text(child_result.final_output, 220),
|
||
'resume_used': resume_used,
|
||
'resumed_from_session_id': (
|
||
str(resume_session_id) if isinstance(resume_session_id, str) and resume_session_id else ''
|
||
),
|
||
'depends_on': list(dependencies), 'batch_index': batch_index,
|
||
}
|
||
return {'result': child_result, 'summary': summary}
|
||
|
||
def _run_batch_parallel(
|
||
self,
|
||
subtasks: list[dict[str, object]],
|
||
*,
|
||
agent_def: 'AgentDefinition',
|
||
child_model_config: 'ModelConfig',
|
||
child_runtime_config: 'AgentRuntimeConfig',
|
||
child_tools: dict[str, 'AgentTool'],
|
||
group_id: str | None,
|
||
batch_index: int,
|
||
include_parent_context: bool,
|
||
prior_results: list[dict[str, str]],
|
||
delegate_preflight_messages: tuple[str, ...],
|
||
max_concurrency: int = 4,
|
||
) -> list[dict[str, object]]:
|
||
"""Run subtasks in a batch concurrently using threads. Returns results in original order."""
|
||
results: list[dict[str, object] | None] = [None] * len(subtasks)
|
||
lock = threading.Lock()
|
||
|
||
def _worker(idx: int, subtask: dict[str, object]) -> None:
|
||
result_info = self._run_single_subtask(
|
||
subtask,
|
||
agent_def=agent_def,
|
||
child_model_config=child_model_config,
|
||
child_runtime_config=child_runtime_config,
|
||
child_tools=child_tools,
|
||
group_id=group_id,
|
||
batch_index=batch_index,
|
||
include_parent_context=include_parent_context,
|
||
prior_results=prior_results,
|
||
delegate_preflight_messages=delegate_preflight_messages,
|
||
)
|
||
with lock:
|
||
results[idx] = result_info
|
||
|
||
with ThreadPoolExecutor(max_workers=min(max_concurrency, len(subtasks))) as executor:
|
||
futures = {
|
||
executor.submit(_worker, idx, subtask): idx
|
||
for idx, subtask in enumerate(subtasks)
|
||
}
|
||
for future in as_completed(futures):
|
||
future.result()
|
||
|
||
return [r for r in results if r is not None]
|
||
|
||
def _normalize_delegate_strategy(self, strategy: object) -> str:
|
||
if not isinstance(strategy, str) or not strategy.strip():
|
||
return 'serial'
|
||
normalized = strategy.strip().lower().replace('-', '_')
|
||
if normalized in {'graph', 'topological', 'dependency_graph', 'parallel', 'parallel_batches'}:
|
||
return 'topological'
|
||
return 'serial'
|
||
|
||
def _plan_delegate_batches(
|
||
self,
|
||
subtasks: list[dict[str, object]],
|
||
strategy: str,
|
||
) -> list[list[dict[str, object]]]:
|
||
if strategy != 'topological':
|
||
return [subtasks]
|
||
remaining = list(subtasks)
|
||
scheduled_labels: set[str] = set()
|
||
known_labels = {
|
||
str(task.get('label'))
|
||
for task in subtasks
|
||
if isinstance(task.get('label'), str) and str(task.get('label')).strip()
|
||
}
|
||
batches: list[list[dict[str, object]]] = []
|
||
while remaining:
|
||
ready: list[dict[str, object]] = []
|
||
blocked: list[dict[str, object]] = []
|
||
for task in remaining:
|
||
dependencies = tuple(
|
||
item
|
||
for item in task.get('depends_on', ())
|
||
if isinstance(item, str) and item
|
||
)
|
||
if any(dependency not in known_labels for dependency in dependencies):
|
||
blocked.append(task)
|
||
continue
|
||
if all(dependency in scheduled_labels for dependency in dependencies):
|
||
ready.append(task)
|
||
else:
|
||
blocked.append(task)
|
||
if not ready:
|
||
batches.append(blocked)
|
||
break
|
||
batches.append(
|
||
sorted(
|
||
ready,
|
||
key=lambda task: int(task.get('_delegate_index', 0)),
|
||
)
|
||
)
|
||
scheduled_labels.update(
|
||
str(task.get('label'))
|
||
for task in ready
|
||
if isinstance(task.get('label'), str) and str(task.get('label')).strip()
|
||
)
|
||
remaining = blocked
|
||
return batches
|
||
|
||
def _delegated_task_units(
|
||
self,
|
||
arguments: dict[str, object],
|
||
) -> int:
|
||
subtasks = arguments.get('subtasks')
|
||
if isinstance(subtasks, list):
|
||
count = sum(
|
||
1
|
||
for item in subtasks
|
||
if (
|
||
isinstance(item, str)
|
||
and item.strip()
|
||
) or (
|
||
isinstance(item, dict)
|
||
and isinstance(item.get('prompt'), str)
|
||
and item.get('prompt', '').strip()
|
||
)
|
||
)
|
||
if count:
|
||
return count
|
||
return 1
|
||
|
||
def _prepend_delegate_context(
|
||
self,
|
||
prompt: str,
|
||
prior_results: list[dict[str, str]],
|
||
) -> str:
|
||
lines = [
|
||
'<system-reminder>',
|
||
'Prior delegated subtask summaries:',
|
||
]
|
||
for result in prior_results[-4:]:
|
||
lines.append(f"- {result['label']}: {result['output_preview']}")
|
||
lines.extend(['</system-reminder>', '', prompt])
|
||
return '\n'.join(lines)
|
||
|
||
def _prepend_plugin_delegate_context(
|
||
self,
|
||
prompt: str,
|
||
messages: tuple[str, ...],
|
||
) -> str:
|
||
if not messages:
|
||
return prompt
|
||
lines = [
|
||
'<system-reminder>',
|
||
'Plugin delegate guidance:',
|
||
]
|
||
lines.extend(f'- {message}' for message in messages)
|
||
lines.extend(['</system-reminder>', '', prompt])
|
||
return '\n'.join(lines)
|
||
|
||
def _append_runtime_tool_followup_events(
|
||
self,
|
||
stream_events: list[dict[str, object]],
|
||
*,
|
||
tool_call: ToolCall,
|
||
tool_result: ToolExecutionResult,
|
||
) -> None:
|
||
metadata = tool_result.metadata
|
||
if metadata.get('action') == 'plugin_virtual_tool':
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_virtual_tool_result',
|
||
'tool_call_id': tool_call.id,
|
||
'tool_name': tool_call.name,
|
||
'plugin_name': metadata.get('plugin_name'),
|
||
'virtual_tool': metadata.get('virtual_tool'),
|
||
}
|
||
)
|
||
plugin_delegate_preflight = metadata.get('plugin_delegate_preflight_messages')
|
||
if isinstance(plugin_delegate_preflight, list) and plugin_delegate_preflight:
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_delegate_preflight',
|
||
'tool_call_id': tool_call.id,
|
||
'tool_name': tool_call.name,
|
||
'message_count': len(plugin_delegate_preflight),
|
||
}
|
||
)
|
||
plugin_delegate_after = metadata.get('plugin_delegate_after_messages')
|
||
if isinstance(plugin_delegate_after, list) and plugin_delegate_after:
|
||
stream_events.append(
|
||
{
|
||
'type': 'plugin_delegate_after',
|
||
'tool_call_id': tool_call.id,
|
||
'tool_name': tool_call.name,
|
||
'message_count': len(plugin_delegate_after),
|
||
}
|
||
)
|
||
if tool_call.name not in ('Agent', 'delegate_agent'):
|
||
return
|
||
delegate_batches = metadata.get('delegate_batches')
|
||
if isinstance(delegate_batches, list):
|
||
for batch in delegate_batches:
|
||
if not isinstance(batch, dict):
|
||
continue
|
||
stream_events.append(
|
||
{
|
||
'type': 'delegate_batch_result',
|
||
'tool_call_id': tool_call.id,
|
||
'group_id': metadata.get('group_id'),
|
||
'batch_index': batch.get('batch_index'),
|
||
'status': batch.get('status'),
|
||
'labels': batch.get('labels'),
|
||
'completed_children': batch.get('completed_children'),
|
||
'failed_children': batch.get('failed_children'),
|
||
'skipped_children': batch.get('skipped_children'),
|
||
}
|
||
)
|
||
child_results = metadata.get('child_results')
|
||
if isinstance(child_results, list):
|
||
for child in child_results:
|
||
if not isinstance(child, dict):
|
||
continue
|
||
stream_events.append(
|
||
{
|
||
'type': 'delegate_subtask_result',
|
||
'tool_call_id': tool_call.id,
|
||
'group_id': metadata.get('group_id'),
|
||
'label': child.get('label'),
|
||
'index': child.get('index'),
|
||
'session_id': child.get('session_id'),
|
||
'stop_reason': child.get('stop_reason'),
|
||
'tool_calls': child.get('tool_calls'),
|
||
'turns': child.get('turns'),
|
||
'resume_used': child.get('resume_used'),
|
||
'resumed_from_session_id': child.get('resumed_from_session_id'),
|
||
'depends_on': child.get('depends_on'),
|
||
'batch_index': child.get('batch_index'),
|
||
}
|
||
)
|
||
if metadata.get('group_id') is not None:
|
||
stream_events.append(
|
||
{
|
||
'type': 'delegate_group_result',
|
||
'tool_call_id': tool_call.id,
|
||
'group_id': metadata.get('group_id'),
|
||
'group_status': metadata.get('group_status'),
|
||
'subtask_count': metadata.get('subtask_count'),
|
||
'completed_children': metadata.get('completed_children'),
|
||
'failed_children': metadata.get('failed_children'),
|
||
'resumed_children': metadata.get('resumed_children'),
|
||
'strategy': metadata.get('strategy'),
|
||
'max_failures': metadata.get('max_failures'),
|
||
'batch_count': len(delegate_batches) if isinstance(delegate_batches, list) else 0,
|
||
'dependency_skips': metadata.get('dependency_skips'),
|
||
}
|
||
)
|
||
|
||
def _preview_text(self, text: str, limit: int) -> str:
|
||
normalized = ' '.join(text.split())
|
||
if len(normalized) <= limit:
|
||
return normalized
|
||
return normalized[: limit - 3] + '...'
|
||
|
||
def _ensure_scratchpad_directory(self, session_id: str) -> Path:
|
||
scratchpad_directory = (
|
||
self.runtime_config.scratchpad_root / session_id / 'scratchpad'
|
||
).resolve()
|
||
scratchpad_directory.mkdir(parents=True, exist_ok=True)
|
||
return scratchpad_directory
|
||
|
||
def _bind_session_plan_task_runtime(self, scratchpad_directory: Path | None) -> None:
|
||
if scratchpad_directory is None:
|
||
self.plan_runtime = PlanRuntime()
|
||
self.task_runtime = TaskRuntime()
|
||
else:
|
||
self.plan_runtime = PlanRuntime.from_storage_path(
|
||
scratchpad_directory / 'plan_runtime.json'
|
||
)
|
||
self.task_runtime = TaskRuntime.from_storage_path(
|
||
scratchpad_directory / 'task_runtime.json'
|
||
)
|
||
self.tool_context = replace(
|
||
self.tool_context,
|
||
plan_runtime=self.plan_runtime,
|
||
task_runtime=self.task_runtime,
|
||
scratchpad_directory=scratchpad_directory,
|
||
)
|
||
|
||
def _session_plan_runtime_path(self) -> Path:
|
||
scratchpad_directory = self.tool_context.scratchpad_directory
|
||
if scratchpad_directory is None:
|
||
return PlanRuntime().storage_path
|
||
return scratchpad_directory / 'plan_runtime.json'
|
||
|
||
def _session_task_runtime_path(self) -> Path:
|
||
scratchpad_directory = self.tool_context.scratchpad_directory
|
||
if scratchpad_directory is None:
|
||
return TaskRuntime().storage_path
|
||
return scratchpad_directory / 'task_runtime.json'
|
||
|
||
def _append_file_history_replay_if_needed(
|
||
self,
|
||
session: AgentSessionState,
|
||
file_history: tuple[dict[str, object], ...],
|
||
) -> None:
|
||
if not file_history:
|
||
return
|
||
replay_count = len(file_history)
|
||
unique_paths = sorted(
|
||
{
|
||
path
|
||
for entry in file_history
|
||
for path in (
|
||
entry.get('changed_paths')
|
||
if isinstance(entry.get('changed_paths'), list)
|
||
else ([entry.get('path')] if isinstance(entry.get('path'), str) else [])
|
||
)
|
||
if isinstance(path, str) and path
|
||
}
|
||
)
|
||
snapshot_count = sum(
|
||
1
|
||
for entry in file_history
|
||
for key in ('before_snapshot_id', 'after_snapshot_id')
|
||
if isinstance(entry.get(key), str) and entry.get(key)
|
||
)
|
||
for message in reversed(session.messages):
|
||
if message.metadata.get('kind') != 'file_history_replay':
|
||
continue
|
||
if message.metadata.get('file_history_count') == replay_count:
|
||
return
|
||
break
|
||
session.append_user(
|
||
self._render_file_history_replay(file_history),
|
||
metadata={
|
||
'kind': 'file_history_replay',
|
||
'file_history_count': replay_count,
|
||
'file_history_unique_paths': len(unique_paths),
|
||
'file_history_snapshot_count': snapshot_count,
|
||
},
|
||
message_id=f'file_history_replay_{replay_count}',
|
||
)
|
||
|
||
def _render_file_history_replay(
|
||
self,
|
||
file_history: tuple[dict[str, object], ...],
|
||
) -> str:
|
||
unique_paths = sorted(
|
||
{
|
||
path
|
||
for entry in file_history
|
||
for path in (
|
||
entry.get('changed_paths')
|
||
if isinstance(entry.get('changed_paths'), list)
|
||
else ([entry.get('path')] if isinstance(entry.get('path'), str) else [])
|
||
)
|
||
if isinstance(path, str) and path
|
||
}
|
||
)
|
||
snapshot_count = sum(
|
||
1
|
||
for entry in file_history
|
||
for key in ('before_snapshot_id', 'after_snapshot_id')
|
||
if isinstance(entry.get(key), str) and entry.get(key)
|
||
)
|
||
lines = [
|
||
'<system-reminder>',
|
||
'Recent file history from this saved session:',
|
||
f'- History entries: {len(file_history)}',
|
||
f'- Unique changed paths: {len(unique_paths)}',
|
||
f'- Snapshot ids: {snapshot_count}',
|
||
]
|
||
if unique_paths:
|
||
preview_paths = ', '.join(unique_paths[:4])
|
||
if len(unique_paths) > 4:
|
||
preview_paths += f', ... (+{len(unique_paths) - 4} more)'
|
||
lines.append(f'- Changed path preview: {preview_paths}')
|
||
for entry in file_history[-10:]:
|
||
action = str(entry.get('action', entry.get('tool_name', 'tool')))
|
||
turn = entry.get('turn_index')
|
||
path = entry.get('path')
|
||
command = entry.get('command')
|
||
details = [f'action={action}']
|
||
history_entry_id = entry.get('history_entry_id')
|
||
if isinstance(history_entry_id, str) and history_entry_id:
|
||
details.append(f'entry_id={history_entry_id}')
|
||
if turn is not None:
|
||
details.append(f'turn={turn}')
|
||
if path:
|
||
details.append(f'path={path}')
|
||
if command:
|
||
details.append(f'command={command}')
|
||
child_session_ids = entry.get('child_session_ids')
|
||
if isinstance(child_session_ids, list) and child_session_ids:
|
||
details.append(f'child_sessions={len(child_session_ids)}')
|
||
delegate_batch_count = entry.get('delegate_batch_count')
|
||
if isinstance(delegate_batch_count, int) and not isinstance(delegate_batch_count, bool):
|
||
details.append(f'batches={delegate_batch_count}')
|
||
dependency_skips = entry.get('dependency_skips')
|
||
if isinstance(dependency_skips, int) and not isinstance(dependency_skips, bool):
|
||
details.append(f'dependency_skips={dependency_skips}')
|
||
lines.append(f"- {'; '.join(details)}")
|
||
before_snapshot_id = entry.get('before_snapshot_id')
|
||
if isinstance(before_snapshot_id, str) and before_snapshot_id:
|
||
lines.append(f' before_snapshot: {before_snapshot_id}')
|
||
after_snapshot_id = entry.get('after_snapshot_id')
|
||
if isinstance(after_snapshot_id, str) and after_snapshot_id:
|
||
lines.append(f' after_snapshot: {after_snapshot_id}')
|
||
before_preview = entry.get('before_preview')
|
||
if isinstance(before_preview, str) and before_preview:
|
||
lines.append(f' before: {before_preview}')
|
||
after_preview = entry.get('after_preview')
|
||
if isinstance(after_preview, str) and after_preview:
|
||
lines.append(f' after: {after_preview}')
|
||
result_preview = entry.get('result_preview')
|
||
if isinstance(result_preview, str) and result_preview:
|
||
lines.append(f' result: {result_preview}')
|
||
if len(file_history) > 10:
|
||
lines.append(f'- ... plus {len(file_history) - 10} older file-history entries')
|
||
lines.extend(
|
||
[
|
||
'',
|
||
'Use this replayed history when continuing the task so you avoid repeating prior edits or commands.',
|
||
'</system-reminder>',
|
||
]
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
def _append_compaction_replay_if_needed(
|
||
self,
|
||
session: AgentSessionState,
|
||
) -> None:
|
||
compact_messages = [
|
||
message for message in session.messages
|
||
if message.metadata.get('kind') == 'compact_boundary'
|
||
]
|
||
snipped_messages = [
|
||
message for message in session.messages
|
||
if message.metadata.get('kind') == 'snipped_message'
|
||
]
|
||
if not compact_messages and not snipped_messages:
|
||
return
|
||
for message in reversed(session.messages):
|
||
if message.metadata.get('kind') != 'compaction_replay':
|
||
continue
|
||
return
|
||
session.append_user(
|
||
self._render_compaction_replay(compact_messages, snipped_messages),
|
||
metadata={
|
||
'kind': 'compaction_replay',
|
||
'compact_boundary_count': len(compact_messages),
|
||
'snipped_message_count': len(snipped_messages),
|
||
},
|
||
message_id=(
|
||
f'compaction_replay_{len(compact_messages)}_{len(snipped_messages)}'
|
||
),
|
||
)
|
||
|
||
def _render_compaction_replay(
|
||
self,
|
||
compact_messages,
|
||
snipped_messages,
|
||
) -> str:
|
||
lines = [
|
||
'<system-reminder>',
|
||
'This resumed session already contains compacted or snipped history.',
|
||
f'- Compact boundaries: {len(compact_messages)}',
|
||
f'- Snipped/tombstoned messages: {len(snipped_messages)}',
|
||
]
|
||
latest_boundary = compact_messages[-1] if compact_messages else None
|
||
if latest_boundary is not None:
|
||
lines.append(
|
||
f"- Latest compact boundary id: {latest_boundary.message_id or '(none)'}"
|
||
)
|
||
depth = latest_boundary.metadata.get('compaction_depth')
|
||
if isinstance(depth, int) and not isinstance(depth, bool):
|
||
lines.append(f'- Latest compaction depth: {depth}')
|
||
compacted_lineages = latest_boundary.metadata.get('compacted_lineage_ids')
|
||
if isinstance(compacted_lineages, list) and compacted_lineages:
|
||
lines.append(f'- Latest compacted lineages: {len(compacted_lineages)}')
|
||
max_source_mutation_serial = latest_boundary.metadata.get('max_source_mutation_serial')
|
||
if (
|
||
isinstance(max_source_mutation_serial, int)
|
||
and not isinstance(max_source_mutation_serial, bool)
|
||
and max_source_mutation_serial > 0
|
||
):
|
||
lines.append(
|
||
f'- Latest source mutation serial: {max_source_mutation_serial}'
|
||
)
|
||
source_mutation_totals = latest_boundary.metadata.get('source_mutation_totals')
|
||
if isinstance(source_mutation_totals, dict) and source_mutation_totals:
|
||
rendered = ', '.join(
|
||
f'{name}:{count}'
|
||
for name, count in sorted(source_mutation_totals.items())
|
||
if isinstance(name, str)
|
||
and name
|
||
and isinstance(count, int)
|
||
and not isinstance(count, bool)
|
||
and count > 0
|
||
)
|
||
if rendered:
|
||
lines.append(f'- Latest compacted mutations: {rendered}')
|
||
preserved_tail = latest_boundary.metadata.get('preserved_tail_ids')
|
||
if isinstance(preserved_tail, list) and preserved_tail:
|
||
lines.append(
|
||
'- Latest preserved tail ids: '
|
||
+ ', '.join(str(item) for item in preserved_tail[:4])
|
||
)
|
||
if snipped_messages:
|
||
last_ids = [
|
||
message.message_id or '(none)'
|
||
for message in snipped_messages[-3:]
|
||
]
|
||
lines.append(f"- Recent snipped ids: {', '.join(last_ids)}")
|
||
snipped_lineages = [
|
||
str(message.metadata.get('snipped_from_lineage_id'))
|
||
for message in snipped_messages[-3:]
|
||
if isinstance(message.metadata.get('snipped_from_lineage_id'), str)
|
||
]
|
||
if snipped_lineages:
|
||
lines.append(f"- Recent snipped lineages: {', '.join(snipped_lineages)}")
|
||
lines.extend(
|
||
[
|
||
'',
|
||
'Use the surviving transcript plus the compacted summaries as the authoritative context when continuing.',
|
||
'</system-reminder>',
|
||
]
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
def _apply_hook_policy_before_prompt_hooks(self, prompt: str) -> str:
|
||
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
|
||
return prompt
|
||
injections = self.hook_policy_runtime.before_prompt_messages()
|
||
managed_settings = self.hook_policy_runtime.managed_settings()
|
||
safe_env = self.hook_policy_runtime.safe_env()
|
||
trusted = self.hook_policy_runtime.is_trusted()
|
||
if not injections and not managed_settings and not safe_env and trusted:
|
||
return prompt
|
||
lines = ['<system-reminder>', 'Workspace hook/policy guidance:']
|
||
lines.append(
|
||
f'- Trust mode: {"trusted" if trusted else "untrusted"}'
|
||
)
|
||
if not trusted:
|
||
lines.append(
|
||
'- Untrusted workspaces should favor inspection-first behavior. '
|
||
'Avoid unnecessary writes or shell actions unless the task clearly requires them.'
|
||
)
|
||
for entry in injections:
|
||
lines.append(f'- Before prompt: {entry}')
|
||
if managed_settings:
|
||
lines.append(
|
||
'- Managed settings: '
|
||
+ ', '.join(f'{key}={value}' for key, value in sorted(managed_settings.items()))
|
||
)
|
||
if safe_env:
|
||
lines.append(
|
||
'- Safe environment values loaded for tools: '
|
||
+ ', '.join(sorted(safe_env))
|
||
)
|
||
lines.extend(['</system-reminder>', '', prompt])
|
||
return '\n'.join(lines)
|
||
|
||
def _build_plugin_tool_runtime_message(
|
||
self,
|
||
*,
|
||
tool_name: str,
|
||
preflight_messages: tuple[str, ...],
|
||
block_message: str | None,
|
||
plugin_messages: tuple[str, ...],
|
||
hook_policy_preflight_messages: tuple[str, ...] = (),
|
||
hook_policy_block_message: str | None = None,
|
||
hook_policy_messages: tuple[str, ...] = (),
|
||
delegate_preflight_messages: tuple[str, ...] = (),
|
||
delegate_after_messages: tuple[str, ...] = (),
|
||
) -> str | None:
|
||
if (
|
||
block_message is None
|
||
and not plugin_messages
|
||
and not preflight_messages
|
||
and hook_policy_block_message is None
|
||
and not hook_policy_preflight_messages
|
||
and not hook_policy_messages
|
||
and not delegate_preflight_messages
|
||
and not delegate_after_messages
|
||
):
|
||
return None
|
||
plugin_only = (
|
||
hook_policy_block_message is None
|
||
and not hook_policy_preflight_messages
|
||
and not hook_policy_messages
|
||
)
|
||
lines = [
|
||
'<system-reminder>',
|
||
(
|
||
f'Plugin tool runtime guidance for `{tool_name}`:'
|
||
if plugin_only
|
||
else f'Runtime tool guidance for `{tool_name}`:'
|
||
),
|
||
]
|
||
for message in preflight_messages:
|
||
lines.append(f'- Before tool: {message}')
|
||
for message in hook_policy_preflight_messages:
|
||
lines.append(f'- Hook/policy before tool: {message}')
|
||
for message in delegate_preflight_messages:
|
||
lines.append(f'- Before delegate: {message}')
|
||
if block_message is not None:
|
||
lines.append(f'- Blocked: {block_message}')
|
||
if hook_policy_block_message is not None:
|
||
lines.append(f'- Hook/policy blocked: {hook_policy_block_message}')
|
||
for message in plugin_messages:
|
||
lines.append(f'- After result: {message}')
|
||
for message in hook_policy_messages:
|
||
lines.append(f'- Hook/policy after result: {message}')
|
||
for message in delegate_after_messages:
|
||
lines.append(f'- After delegate: {message}')
|
||
lines.extend(
|
||
[
|
||
'',
|
||
'Use this runtime guidance when deciding the next tool call or assistant response.',
|
||
'</system-reminder>',
|
||
]
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
def _plugin_tool_preflight_messages(self, tool_name: str) -> tuple[str, ...]:
|
||
if self.plugin_runtime is None:
|
||
return ()
|
||
return self.plugin_runtime.tool_preflight_injections(tool_name)
|
||
|
||
def _plugin_block_message(self, tool_name: str) -> str | None:
|
||
if self.plugin_runtime is None:
|
||
return None
|
||
return self.plugin_runtime.blocked_tool_message(tool_name)
|
||
|
||
def _plugin_tool_result_messages(self, tool_name: str) -> tuple[str, ...]:
|
||
if self.plugin_runtime is None:
|
||
return ()
|
||
return self.plugin_runtime.tool_result_injections(tool_name)
|
||
|
||
def _hook_policy_tool_preflight_messages(self, tool_name: str) -> tuple[str, ...]:
|
||
if self.hook_policy_runtime is None:
|
||
return ()
|
||
return self.hook_policy_runtime.before_tool_messages(tool_name)
|
||
|
||
def _hook_policy_block_message(self, tool_name: str) -> str | None:
|
||
if self.hook_policy_runtime is None:
|
||
return None
|
||
return self.hook_policy_runtime.denied_tool_message(tool_name)
|
||
|
||
def _hook_policy_tool_result_messages(self, tool_name: str) -> tuple[str, ...]:
|
||
if self.hook_policy_runtime is None:
|
||
return ()
|
||
return self.hook_policy_runtime.after_tool_messages(tool_name)
|
||
|
||
def _persist_session(
|
||
self,
|
||
session: AgentSessionState,
|
||
result: AgentRunResult,
|
||
) -> AgentRunResult:
|
||
if result.session_id is None:
|
||
return result
|
||
persist_events = list(result.events)
|
||
if self.plugin_runtime is not None:
|
||
persist_messages = self.plugin_runtime.before_persist_injections()
|
||
if persist_messages:
|
||
session.append_user(
|
||
self._render_plugin_persist_message(persist_messages),
|
||
metadata={
|
||
'kind': 'plugin_persist',
|
||
'message_count': len(persist_messages),
|
||
},
|
||
message_id=f'plugin_persist_{result.session_id}',
|
||
)
|
||
persist_events.append(
|
||
{
|
||
'type': 'plugin_before_persist',
|
||
'session_id': result.session_id,
|
||
'message_count': len(persist_messages),
|
||
}
|
||
)
|
||
previous_turns = 0
|
||
previous_tool_calls = 0
|
||
previous_budget_state: dict[str, object] = {}
|
||
existing_path = (
|
||
self.runtime_config.session_directory / result.session_id / 'session.json'
|
||
)
|
||
if not existing_path.exists():
|
||
existing_path = self.runtime_config.session_directory / f'{result.session_id}.json'
|
||
if existing_path.exists():
|
||
try:
|
||
previous = load_agent_session(
|
||
result.session_id,
|
||
directory=self.runtime_config.session_directory,
|
||
)
|
||
except OSError:
|
||
previous = None
|
||
if previous is not None:
|
||
previous_turns = previous.turns
|
||
previous_tool_calls = previous.tool_calls
|
||
if isinstance(previous.budget_state, dict):
|
||
previous_budget_state = dict(previous.budget_state)
|
||
budget_state = {
|
||
'model_calls': int(previous_budget_state.get('model_calls', 0))
|
||
+ max(result.turns, 0),
|
||
'session_turns': previous_turns + result.turns,
|
||
'tool_calls': previous_tool_calls + result.tool_calls,
|
||
'delegated_tasks': sum(
|
||
1 for entry in result.file_history if entry.get('action') in ('delegate_agent', 'Agent')
|
||
),
|
||
}
|
||
stored = StoredAgentSession(
|
||
session_id=result.session_id,
|
||
model_config=serialize_model_config(self.model_config),
|
||
runtime_config=serialize_runtime_config(self.runtime_config),
|
||
system_prompt_parts=session.system_prompt_parts,
|
||
user_context=dict(session.user_context),
|
||
system_context=dict(session.system_context),
|
||
messages=session.transcript(),
|
||
turns=previous_turns + result.turns,
|
||
tool_calls=previous_tool_calls + result.tool_calls,
|
||
usage=result.usage.to_dict(),
|
||
total_cost_usd=result.total_cost_usd,
|
||
file_history=result.file_history,
|
||
budget_state=budget_state,
|
||
plugin_state=(
|
||
self.plugin_runtime.export_session_state()
|
||
if self.plugin_runtime is not None
|
||
else {}
|
||
),
|
||
scratchpad_directory=result.scratchpad_directory,
|
||
session_metadata=dict(self.session_metadata),
|
||
)
|
||
path = save_agent_session(
|
||
stored,
|
||
directory=self.runtime_config.session_directory,
|
||
)
|
||
self.last_session_path = str(path)
|
||
return replace(
|
||
result,
|
||
session_path=self.last_session_path,
|
||
events=tuple(persist_events),
|
||
transcript=session.transcript(),
|
||
)
|
||
|
||
def render_system_prompt(self) -> str:
|
||
prompt_context = self.build_prompt_context()
|
||
parts = self.build_system_prompt_parts(prompt_context)
|
||
return render_system_prompt(parts)
|
||
|
||
def render_context_report(self, prompt: str | None = None) -> str:
|
||
session = self.last_session if prompt is None else None
|
||
strategy = 'current Python session'
|
||
if session is None:
|
||
session = self.build_session(prompt)
|
||
strategy = 'one-shot Python session preview'
|
||
report = collect_context_usage(
|
||
session=session,
|
||
model=self.model_config.model,
|
||
strategy=strategy,
|
||
)
|
||
return format_context_usage(report)
|
||
|
||
def render_context_snapshot_report(self) -> str:
|
||
prompt_context = self.build_prompt_context()
|
||
return render_agent_context_report(prompt_context, self.model_config.model)
|
||
|
||
def render_permissions_report(self) -> str:
|
||
permissions = self.runtime_config.permissions
|
||
lines = [
|
||
'# Permissions',
|
||
'',
|
||
f'- File write tools: {"enabled" if permissions.allow_file_write else "disabled"}',
|
||
f'- Shell commands: {"enabled" if permissions.allow_shell_commands else "disabled"}',
|
||
f'- Destructive shell commands: {"enabled" if permissions.allow_destructive_shell_commands else "disabled"}',
|
||
]
|
||
if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests:
|
||
lines.append(
|
||
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
|
||
)
|
||
denied_tools = sorted(
|
||
{
|
||
name
|
||
for manifest in self.hook_policy_runtime.manifests
|
||
for name in manifest.deny_tools
|
||
}
|
||
)
|
||
if denied_tools:
|
||
lines.append('- Policy-denied tools: ' + ', '.join(denied_tools))
|
||
return '\n'.join(lines)
|
||
|
||
def render_tools_report(self) -> str:
|
||
permissions = self.runtime_config.permissions
|
||
lines = ['# Tools', '']
|
||
for tool in self.tool_registry.values():
|
||
state = 'enabled'
|
||
if tool.name == 'bash' and not permissions.allow_shell_commands:
|
||
state = 'blocked by permissions'
|
||
if tool.name in {'write_file', 'edit_file'} and not permissions.allow_file_write:
|
||
state = 'blocked by permissions'
|
||
if (
|
||
self.hook_policy_runtime is not None
|
||
and self.hook_policy_runtime.denied_tool_message(tool.name) is not None
|
||
):
|
||
state = 'blocked by hook policy'
|
||
lines.append(f'- `{tool.name}`: {tool.description} [{state}]')
|
||
return '\n'.join(lines)
|
||
|
||
def render_agents_report(self, *, show_all: bool = False) -> str:
|
||
snapshot = self.load_agent_registry()
|
||
return render_agents_report(
|
||
snapshot,
|
||
cwd=self.runtime_config.cwd,
|
||
show_all=show_all,
|
||
)
|
||
|
||
def render_agent_detail_report(self, agent_type: str) -> str:
|
||
snapshot = self.load_agent_registry()
|
||
return render_agent_detail(snapshot, agent_type)
|
||
|
||
def render_agent_create_report(
|
||
self,
|
||
agent_type: str,
|
||
*,
|
||
source: str = 'projectSettings',
|
||
description: str | None = None,
|
||
system_prompt: str | None = None,
|
||
overwrite: bool = False,
|
||
) -> str:
|
||
result = scaffold_agent_definition(
|
||
self.runtime_config.cwd,
|
||
agent_type=agent_type,
|
||
source=normalize_mutable_source(source),
|
||
overwrite=overwrite,
|
||
description=description,
|
||
system_prompt=system_prompt,
|
||
)
|
||
return render_agent_mutation(result)
|
||
|
||
def render_agent_update_report(
|
||
self,
|
||
agent_type: str,
|
||
*,
|
||
source: str = 'auto',
|
||
description: str | None = None,
|
||
system_prompt: str | None = None,
|
||
) -> str:
|
||
update_kwargs: dict[str, object] = {}
|
||
if description is not None:
|
||
update_kwargs['description'] = description
|
||
if system_prompt is not None:
|
||
update_kwargs['system_prompt'] = system_prompt
|
||
result = update_agent_definition(
|
||
self.runtime_config.cwd,
|
||
agent_type=agent_type,
|
||
source=normalize_mutable_source(source, allow_auto=True),
|
||
**update_kwargs,
|
||
)
|
||
return render_agent_mutation(result)
|
||
|
||
def render_agent_delete_report(
|
||
self,
|
||
agent_type: str,
|
||
*,
|
||
source: str = 'auto',
|
||
) -> str:
|
||
result = delete_agent_definition(
|
||
self.runtime_config.cwd,
|
||
agent_type=agent_type,
|
||
source=normalize_mutable_source(source, allow_auto=True),
|
||
)
|
||
return render_agent_mutation(result)
|
||
|
||
def render_memory_report(self) -> str:
|
||
prompt_context = self.build_prompt_context()
|
||
claude_md = prompt_context.user_context.get('claudeMd')
|
||
if not claude_md:
|
||
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
||
return '\n'.join(['# Memory', '', claude_md])
|
||
|
||
def render_account_report(self, profile: str | None = None) -> str:
|
||
if self.account_runtime is None:
|
||
return '# Account\n\nNo local account runtime is available.'
|
||
if profile:
|
||
return self.account_runtime.render_profile(profile)
|
||
return '\n'.join(['# Account', '', self.account_runtime.render_summary()])
|
||
|
||
def render_search_report(
|
||
self,
|
||
query: str | None = None,
|
||
*,
|
||
provider: str | None = None,
|
||
max_results: int = 5,
|
||
domains: tuple[str, ...] = (),
|
||
) -> str:
|
||
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
|
||
return (
|
||
'# Search\n\nNo local search provider is available. '
|
||
'Add a .claw-search.json or .claude/search.json manifest, '
|
||
'or set SEARXNG_BASE_URL, BRAVE_SEARCH_API_KEY, or TAVILY_API_KEY.'
|
||
)
|
||
if query:
|
||
try:
|
||
return self.search_runtime.render_search_results(
|
||
query,
|
||
provider_name=provider,
|
||
max_results=max_results,
|
||
domains=domains,
|
||
timeout_seconds=self.runtime_config.command_timeout_seconds,
|
||
)
|
||
except (KeyError, LookupError, OSError, ValueError) as exc:
|
||
return f'# Search\n\nSearch failed: {exc}'
|
||
if provider:
|
||
return self.search_runtime.render_provider(provider)
|
||
return '\n'.join(['# Search', '', self.search_runtime.render_summary()])
|
||
|
||
def render_search_providers_report(self, query: str | None = None) -> str:
|
||
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
|
||
return '# Search Providers\n\nNo local search providers discovered.'
|
||
return self.search_runtime.render_providers_index(query=query)
|
||
|
||
def render_search_activate_report(self, provider: str) -> str:
|
||
if self.search_runtime is None or not self.search_runtime.has_search_runtime():
|
||
return '# Search\n\nNo local search provider is available.'
|
||
try:
|
||
report = self.search_runtime.activate_provider(provider)
|
||
except KeyError:
|
||
return f'# Search\n\nUnknown search provider: {provider}'
|
||
clear_context_caches()
|
||
self.tool_context = replace(
|
||
self.tool_context,
|
||
search_runtime=self.search_runtime,
|
||
)
|
||
return '\n'.join(['# Search', '', report.as_text()])
|
||
|
||
def render_account_profiles_report(self, query: str | None = None) -> str:
|
||
if self.account_runtime is None:
|
||
return '# Account Profiles\n\nNo local account runtime is available.'
|
||
return self.account_runtime.render_profiles_index(query=query)
|
||
|
||
def render_account_login_report(
|
||
self,
|
||
target: str,
|
||
*,
|
||
provider: str | None = None,
|
||
auth_mode: str | None = None,
|
||
) -> str:
|
||
if self.account_runtime is None:
|
||
return '# Account\n\nNo local account runtime is available.'
|
||
report = self.account_runtime.login(target, provider=provider, auth_mode=auth_mode)
|
||
clear_context_caches()
|
||
return '\n'.join(['# Account', '', report.as_text()])
|
||
|
||
def render_account_logout_report(self) -> str:
|
||
if self.account_runtime is None:
|
||
return '# Account\n\nNo local account runtime is available.'
|
||
report = self.account_runtime.logout(reason='slash_or_cli_logout')
|
||
clear_context_caches()
|
||
return '\n'.join(['# Account', '', report.as_text()])
|
||
|
||
def render_config_report(self) -> str:
|
||
if self.config_runtime is None:
|
||
return '# Config\n\nNo local config runtime is available.'
|
||
return '\n'.join(['# Config', '', self.config_runtime.render_summary()])
|
||
|
||
def render_lsp_report(self) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP\n\nNo local LSP runtime is available.'
|
||
return '\n'.join(['# LSP', '', self.lsp_runtime.render_summary()])
|
||
|
||
def render_lsp_document_symbols_report(self, file_path: str) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Document Symbols\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_document_symbols(file_path)
|
||
except KeyError as exc:
|
||
return f'# LSP Document Symbols\n\n{exc}'
|
||
|
||
def render_lsp_workspace_symbols_report(
|
||
self,
|
||
query: str,
|
||
*,
|
||
max_results: int = 50,
|
||
) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Workspace Symbols\n\nNo local LSP runtime is available.'
|
||
return self.lsp_runtime.render_workspace_symbols(query, max_results=max_results)
|
||
|
||
def render_lsp_definition_report(
|
||
self,
|
||
file_path: str,
|
||
line: int,
|
||
character: int,
|
||
*,
|
||
max_results: int = 20,
|
||
) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Definition\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_definition(
|
||
file_path,
|
||
line,
|
||
character,
|
||
max_results=max_results,
|
||
)
|
||
except KeyError as exc:
|
||
return f'# LSP Definition\n\n{exc}'
|
||
|
||
def render_lsp_references_report(
|
||
self,
|
||
file_path: str,
|
||
line: int,
|
||
character: int,
|
||
*,
|
||
max_results: int = 50,
|
||
) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP References\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_references(
|
||
file_path,
|
||
line,
|
||
character,
|
||
max_results=max_results,
|
||
)
|
||
except KeyError as exc:
|
||
return f'# LSP References\n\n{exc}'
|
||
|
||
def render_lsp_hover_report(self, file_path: str, line: int, character: int) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Hover\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_hover(file_path, line, character)
|
||
except KeyError as exc:
|
||
return f'# LSP Hover\n\n{exc}'
|
||
|
||
def render_lsp_diagnostics_report(self, file_path: str | None = None) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Diagnostics\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_diagnostics(file_path)
|
||
except KeyError as exc:
|
||
return f'# LSP Diagnostics\n\n{exc}'
|
||
|
||
def render_lsp_prepare_call_hierarchy_report(
|
||
self,
|
||
file_path: str,
|
||
line: int,
|
||
character: int,
|
||
) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Call Hierarchy\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_prepare_call_hierarchy(file_path, line, character)
|
||
except KeyError as exc:
|
||
return f'# LSP Call Hierarchy\n\n{exc}'
|
||
|
||
def render_lsp_incoming_calls_report(
|
||
self,
|
||
file_path: str,
|
||
line: int,
|
||
character: int,
|
||
*,
|
||
max_results: int = 50,
|
||
) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Incoming Calls\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_incoming_calls(
|
||
file_path,
|
||
line,
|
||
character,
|
||
max_results=max_results,
|
||
)
|
||
except KeyError as exc:
|
||
return f'# LSP Incoming Calls\n\n{exc}'
|
||
|
||
def render_lsp_outgoing_calls_report(
|
||
self,
|
||
file_path: str,
|
||
line: int,
|
||
character: int,
|
||
*,
|
||
max_results: int = 50,
|
||
) -> str:
|
||
if self.lsp_runtime is None or not self.lsp_runtime.has_lsp_support():
|
||
return '# LSP Outgoing Calls\n\nNo local LSP runtime is available.'
|
||
try:
|
||
return self.lsp_runtime.render_outgoing_calls(
|
||
file_path,
|
||
line,
|
||
character,
|
||
max_results=max_results,
|
||
)
|
||
except KeyError as exc:
|
||
return f'# LSP Outgoing Calls\n\n{exc}'
|
||
|
||
def render_config_effective_report(self) -> str:
|
||
if self.config_runtime is None:
|
||
return '# Config Effective\n\nNo local config runtime is available.'
|
||
return '\n'.join(['# Config Effective', '', self.config_runtime.render_effective_config()])
|
||
|
||
def render_config_source_report(self, source: str) -> str:
|
||
if self.config_runtime is None:
|
||
return '# Config Source\n\nNo local config runtime is available.'
|
||
return '\n'.join(['# Config Source', '', self.config_runtime.render_source(source)])
|
||
|
||
def render_config_value_report(self, key_path: str, source: str | None = None) -> str:
|
||
if self.config_runtime is None:
|
||
return '# Config Value\n\nNo local config runtime is available.'
|
||
try:
|
||
rendered = self.config_runtime.render_value(key_path, source=source)
|
||
except KeyError as exc:
|
||
label = source if source is not None else key_path
|
||
return f'# Config Value\n\nUnknown config key or source: {label or exc.args[0]}'
|
||
return '\n'.join(['# Config Value', '', rendered])
|
||
|
||
def render_mcp_report(self, query: str | None = None) -> str:
|
||
if self.mcp_runtime is None:
|
||
return '# MCP\n\nNo local MCP manifests, servers, or resources discovered.'
|
||
if query:
|
||
return self.mcp_runtime.render_resource_index(query=query)
|
||
return '\n'.join(['# MCP', '', self.mcp_runtime.render_summary()])
|
||
|
||
def render_remote_report(self, target: str | None = None) -> str:
|
||
if self.remote_runtime is None:
|
||
return '# Remote\n\nNo local remote runtime is available.'
|
||
if target:
|
||
report = self.remote_runtime.connect(target)
|
||
clear_context_caches()
|
||
return '\n'.join(['# Remote', '', report.as_text()])
|
||
return '\n'.join(['# Remote', '', self.remote_runtime.render_summary()])
|
||
|
||
def render_remote_mode_report(self, target: str, *, mode: str) -> str:
|
||
if self.remote_runtime is None:
|
||
return '# Remote\n\nNo local remote runtime is available.'
|
||
report = self.remote_runtime.connect(target, mode=mode)
|
||
clear_context_caches()
|
||
return '\n'.join(['# Remote', '', report.as_text()])
|
||
|
||
def render_remote_profiles_report(self, query: str | None = None) -> str:
|
||
if self.remote_runtime is None:
|
||
return '# Remote Profiles\n\nNo local remote runtime is available.'
|
||
return self.remote_runtime.render_profiles_index(query=query)
|
||
|
||
def render_remote_disconnect_report(self) -> str:
|
||
if self.remote_runtime is None:
|
||
return '# Remote\n\nNo local remote runtime is available.'
|
||
report = self.remote_runtime.disconnect()
|
||
clear_context_caches()
|
||
return '\n'.join(['# Remote', '', report.as_text()])
|
||
|
||
def render_worktree_report(self) -> str:
|
||
if self.worktree_runtime is None:
|
||
return '# Worktree\n\nNo local worktree runtime is available.'
|
||
return '\n'.join(['# Worktree', '', self.worktree_runtime.render_summary()])
|
||
|
||
def render_worktree_enter_report(self, name: str | None = None) -> str:
|
||
if self.worktree_runtime is None:
|
||
return '# Worktree\n\nNo local worktree runtime is available.'
|
||
try:
|
||
report = self.worktree_runtime.enter(name=name)
|
||
except (RuntimeError, ValueError) as exc:
|
||
return f'# Worktree\n\n{exc}'
|
||
self._apply_runtime_cwd_update(Path(report.worktree_path or self.runtime_config.cwd))
|
||
return '\n'.join(['# Worktree', '', report.as_text()])
|
||
|
||
def render_worktree_exit_report(
|
||
self,
|
||
*,
|
||
action: str = 'keep',
|
||
discard_changes: bool = False,
|
||
) -> str:
|
||
if self.worktree_runtime is None:
|
||
return '# Worktree\n\nNo local worktree runtime is available.'
|
||
try:
|
||
report = self.worktree_runtime.exit(
|
||
action=action,
|
||
discard_changes=discard_changes,
|
||
)
|
||
except (RuntimeError, ValueError) as exc:
|
||
return f'# Worktree\n\n{exc}'
|
||
target_cwd = report.original_cwd or report.current_cwd or str(self.runtime_config.cwd)
|
||
self._apply_runtime_cwd_update(Path(target_cwd))
|
||
return '\n'.join(['# Worktree', '', report.as_text()])
|
||
|
||
def render_worktree_history_report(self) -> str:
|
||
if self.worktree_runtime is None:
|
||
return '# Worktree History\n\nNo local worktree runtime is available.'
|
||
return self.worktree_runtime.render_history()
|
||
|
||
def render_mcp_resources_report(self, query: str | None = None) -> str:
|
||
if self.mcp_runtime is None:
|
||
return '# MCP Resources\n\nNo local MCP manifests, servers, or resources discovered.'
|
||
return self.mcp_runtime.render_resource_index(query=query)
|
||
|
||
def render_mcp_resource_report(self, uri: str) -> str:
|
||
if self.mcp_runtime is None:
|
||
return '# MCP Resource\n\nNo local MCP manifests, servers, or resources discovered.'
|
||
return self.mcp_runtime.render_resource(uri)
|
||
|
||
def render_mcp_tools_report(
|
||
self,
|
||
query: str | None = None,
|
||
*,
|
||
server: str | None = None,
|
||
) -> str:
|
||
if self.mcp_runtime is None:
|
||
return '# MCP Tools\n\nNo local MCP manifests, servers, or resources discovered.'
|
||
return self.mcp_runtime.render_tool_index(query=query, server_name=server)
|
||
|
||
def render_mcp_call_tool_report(
|
||
self,
|
||
tool_name: str,
|
||
*,
|
||
arguments: dict[str, Any] | None = None,
|
||
server: str | None = None,
|
||
) -> str:
|
||
if self.mcp_runtime is None:
|
||
return '# MCP Tool Result\n\nNo local MCP manifests, servers, or resources discovered.'
|
||
try:
|
||
return self.mcp_runtime.render_tool_call(
|
||
tool_name,
|
||
arguments=arguments,
|
||
server_name=server,
|
||
)
|
||
except FileNotFoundError as exc:
|
||
return f'# MCP Tool Result\n\n{exc}'
|
||
|
||
def render_tasks_report(self, status: str | None = None) -> str:
|
||
if self.task_runtime is None:
|
||
return '# Tasks\n\nNo local task runtime is available.'
|
||
return self.task_runtime.render_tasks(status=status)
|
||
|
||
def render_next_tasks_report(self) -> str:
|
||
if self.task_runtime is None:
|
||
return '# Next Tasks\n\nNo local task runtime is available.'
|
||
return self.task_runtime.render_next_tasks()
|
||
|
||
def render_plan_report(self) -> str:
|
||
if self.plan_runtime is None:
|
||
return '# Plan\n\nNo local plan runtime is available.'
|
||
return self.plan_runtime.render_plan()
|
||
|
||
def render_task_report(self, task_id: str) -> str:
|
||
if self.task_runtime is None:
|
||
return '# Task\n\nNo local task runtime is available.'
|
||
return self.task_runtime.render_task(task_id)
|
||
|
||
def render_ask_user_report(self) -> str:
|
||
if self.ask_user_runtime is None:
|
||
return '# Ask User\n\nNo local ask-user runtime is available.'
|
||
return '\n'.join(['# Ask User', '', self.ask_user_runtime.render_summary()])
|
||
|
||
def render_ask_user_history_report(self) -> str:
|
||
if self.ask_user_runtime is None:
|
||
return '# Ask User History\n\nNo local ask-user runtime is available.'
|
||
return self.ask_user_runtime.render_history()
|
||
|
||
def render_teams_report(self, query: str | None = None) -> str:
|
||
if self.team_runtime is None:
|
||
return '# Teams\n\nNo local team runtime is available.'
|
||
return self.team_runtime.render_teams_index(query=query)
|
||
|
||
def render_team_report(self, team_name: str) -> str:
|
||
if self.team_runtime is None:
|
||
return '# Team\n\nNo local team runtime is available.'
|
||
try:
|
||
return self.team_runtime.render_team(team_name)
|
||
except KeyError:
|
||
return f'# Team\n\nUnknown team: {team_name}'
|
||
|
||
def render_team_messages_report(self, team_name: str | None = None) -> str:
|
||
if self.team_runtime is None:
|
||
return '# Team Messages\n\nNo local team runtime is available.'
|
||
try:
|
||
return self.team_runtime.render_messages(team_name=team_name)
|
||
except KeyError:
|
||
return f'# Team Messages\n\nUnknown team: {team_name}'
|
||
|
||
def render_workflows_report(self, query: str | None = None) -> str:
|
||
if self.workflow_runtime is None or not self.workflow_runtime.has_workflows():
|
||
return '# Workflows\n\nNo local workflow runtime is available.'
|
||
return self.workflow_runtime.render_workflows_index(query=query)
|
||
|
||
def render_workflow_report(self, workflow_name: str) -> str:
|
||
if self.workflow_runtime is None or not self.workflow_runtime.has_workflows():
|
||
return '# Workflow\n\nNo local workflow runtime is available.'
|
||
try:
|
||
return self.workflow_runtime.render_workflow(workflow_name)
|
||
except KeyError:
|
||
return f'# Workflow\n\nUnknown workflow: {workflow_name}'
|
||
|
||
def render_workflow_run_report(
|
||
self,
|
||
workflow_name: str,
|
||
*,
|
||
arguments: dict[str, Any] | None = None,
|
||
) -> str:
|
||
if self.workflow_runtime is None or not self.workflow_runtime.has_workflows():
|
||
return '# Workflow Run\n\nNo local workflow runtime is available.'
|
||
try:
|
||
return self.workflow_runtime.render_run_report(
|
||
workflow_name,
|
||
arguments=arguments,
|
||
)
|
||
except KeyError:
|
||
return f'# Workflow Run\n\nUnknown workflow: {workflow_name}'
|
||
|
||
def render_remote_triggers_report(self, query: str | None = None) -> str:
|
||
if self.remote_trigger_runtime is None or not self.remote_trigger_runtime.has_state():
|
||
return '# Remote Triggers\n\nNo local remote trigger runtime is available.'
|
||
return self.remote_trigger_runtime.render_trigger_index(query=query)
|
||
|
||
def render_remote_trigger_report(self, trigger_id: str) -> str:
|
||
if self.remote_trigger_runtime is None or not self.remote_trigger_runtime.has_state():
|
||
return '# Remote Trigger\n\nNo local remote trigger runtime is available.'
|
||
try:
|
||
return self.remote_trigger_runtime.render_trigger(trigger_id)
|
||
except KeyError:
|
||
return f'# Remote Trigger\n\nUnknown remote trigger: {trigger_id}'
|
||
|
||
def render_remote_trigger_action_report(
|
||
self,
|
||
action: str,
|
||
*,
|
||
trigger_id: str | None = None,
|
||
body: dict[str, Any] | None = None,
|
||
) -> str:
|
||
if self.remote_trigger_runtime is None:
|
||
return '# Remote Trigger\n\nNo local remote trigger runtime is available.'
|
||
normalized = action.strip().lower()
|
||
try:
|
||
if normalized == 'list':
|
||
return self.remote_trigger_runtime.render_trigger_index()
|
||
if normalized == 'get':
|
||
if not trigger_id:
|
||
return '# Remote Trigger\n\ntrigger_id is required for get'
|
||
return self.remote_trigger_runtime.render_trigger(trigger_id)
|
||
if normalized == 'create':
|
||
created = self.remote_trigger_runtime.create_trigger(body or {})
|
||
return self.remote_trigger_runtime.render_trigger(created.trigger_id)
|
||
if normalized == 'update':
|
||
if not trigger_id:
|
||
return '# Remote Trigger\n\ntrigger_id is required for update'
|
||
updated = self.remote_trigger_runtime.update_trigger(trigger_id, body or {})
|
||
return self.remote_trigger_runtime.render_trigger(updated.trigger_id)
|
||
if normalized == 'run':
|
||
if not trigger_id:
|
||
return '# Remote Trigger Run\n\ntrigger_id is required for run'
|
||
return self.remote_trigger_runtime.render_run_report(trigger_id, body=body)
|
||
except (KeyError, TypeError, ValueError) as exc:
|
||
return f'# Remote Trigger\n\n{exc}'
|
||
return '# Remote Trigger\n\naction must be one of list, get, create, update, or run'
|
||
|
||
def render_hook_policy_report(self) -> str:
|
||
if self.hook_policy_runtime is None:
|
||
return '# Hook Policy\n\nNo local hook or policy manifests discovered.'
|
||
return '\n'.join(['# Hook Policy', '', self.hook_policy_runtime.render_summary()])
|
||
|
||
def render_trust_report(self) -> str:
|
||
trusted = True
|
||
settings: dict[str, Any] = {}
|
||
env_values: dict[str, str] = {}
|
||
if self.hook_policy_runtime is not None:
|
||
trusted = self.hook_policy_runtime.is_trusted()
|
||
settings = self.hook_policy_runtime.managed_settings()
|
||
env_values = self.hook_policy_runtime.safe_env()
|
||
lines = [
|
||
'# Trust',
|
||
'',
|
||
f'- Workspace trust mode: {"trusted" if trusted else "untrusted"}',
|
||
]
|
||
if settings:
|
||
lines.append('- Managed settings:')
|
||
lines.extend(f' - {key}={value}' for key, value in sorted(settings.items()))
|
||
if env_values:
|
||
lines.append('- Safe environment values:')
|
||
lines.extend(f' - {key}={value}' for key, value in sorted(env_values.items()))
|
||
return '\n'.join(lines)
|
||
|
||
def render_status_report(self) -> str:
|
||
token_counter = describe_token_counter(self.model_config.model)
|
||
lines = [
|
||
'# Status',
|
||
'',
|
||
f'- Model: {self.model_config.model}',
|
||
f'- Token counter: {token_counter.backend} ({token_counter.source})',
|
||
f'- Registered tools: {len(self.tool_registry)}',
|
||
f'- Streaming model responses: {self.runtime_config.stream_model_responses}',
|
||
f'- Session ID: {self.active_session_id or "none"}',
|
||
f'- Last session loaded: {"yes" if self.last_session is not None else "no"}',
|
||
]
|
||
if self.last_session is not None:
|
||
token_budget = calculate_token_budget(
|
||
session=self.last_session,
|
||
model=self.model_config.model,
|
||
budget_config=self.runtime_config.budget_config,
|
||
output_schema=self.runtime_config.output_schema,
|
||
)
|
||
lines.append(
|
||
f'- Prompt budget: {token_budget.projected_input_tokens:,} / {token_budget.soft_input_limit_tokens:,} soft'
|
||
)
|
||
lines.append(
|
||
f'- Prompt hard limit: {token_budget.hard_input_limit_tokens:,}'
|
||
)
|
||
if self.hook_policy_runtime is not None and self.hook_policy_runtime.manifests:
|
||
lines.append(
|
||
f'- Workspace trust mode: {"trusted" if self.hook_policy_runtime.is_trusted() else "untrusted"}'
|
||
)
|
||
if self.mcp_runtime is not None:
|
||
if self.mcp_runtime.resources:
|
||
lines.append(f'- MCP local resources: {len(self.mcp_runtime.resources)}')
|
||
if self.mcp_runtime.servers:
|
||
lines.append(f'- MCP servers: {len(self.mcp_runtime.servers)}')
|
||
if self.remote_runtime is not None and self.remote_runtime.has_remote_config():
|
||
lines.append(f'- Remote profiles: {len(self.remote_runtime.profiles)}')
|
||
if self.remote_runtime.active_connection is not None:
|
||
connection = self.remote_runtime.active_connection
|
||
lines.append(
|
||
f'- Active remote: {connection.mode} -> {connection.target}'
|
||
)
|
||
if self.search_runtime is not None and self.search_runtime.has_search_runtime():
|
||
lines.append(f'- Search providers: {len(self.search_runtime.providers)}')
|
||
active_provider = self.search_runtime.current_provider()
|
||
if active_provider is not None:
|
||
lines.append(
|
||
f'- Active search provider: {active_provider.name} ({active_provider.provider})'
|
||
)
|
||
if self.account_runtime is not None and self.account_runtime.has_account_state():
|
||
lines.append(f'- Account profiles: {len(self.account_runtime.profiles)}')
|
||
if self.account_runtime.active_session is not None:
|
||
session = self.account_runtime.active_session
|
||
lines.append(
|
||
f'- Active account: {session.provider} -> {session.identity}'
|
||
)
|
||
if self.ask_user_runtime is not None and self.ask_user_runtime.has_state():
|
||
lines.append(f'- Ask-user queued answers: {len(self.ask_user_runtime.queued_answers)}')
|
||
lines.append(f'- Ask-user history: {len(self.ask_user_runtime.history)}')
|
||
if self.config_runtime is not None and self.config_runtime.has_config():
|
||
lines.append(f'- Config sources: {len(self.config_runtime.sources)}')
|
||
lines.append(
|
||
f'- Effective config keys: {len(self.config_runtime.list_keys())}'
|
||
)
|
||
if self.lsp_runtime is not None and self.lsp_runtime.has_lsp_support():
|
||
lines.append(
|
||
f'- LSP indexed files: {len(self.lsp_runtime._workspace_files())}'
|
||
)
|
||
if self.plan_runtime is not None and self.plan_runtime.steps:
|
||
lines.append(f'- Local plan steps: {len(self.plan_runtime.steps)}')
|
||
if self.task_runtime is not None and self.task_runtime.tasks:
|
||
lines.append(f'- Local tasks: {len(self.task_runtime.tasks)}')
|
||
if self.team_runtime is not None and self.team_runtime.has_team_state():
|
||
lines.append(f'- Local teams: {len(self.team_runtime.teams)}')
|
||
lines.append(f'- Team messages: {len(self.team_runtime.messages)}')
|
||
if self.last_session_path is not None:
|
||
lines.append(f'- Session path: {self.last_session_path}')
|
||
if self.last_run_result is not None:
|
||
lines.extend(
|
||
[
|
||
f'- Last run turns: {self.last_run_result.turns}',
|
||
f'- Last run tool calls: {self.last_run_result.tool_calls}',
|
||
f'- Last run total tokens: {self.last_run_result.usage.total_tokens}',
|
||
f'- Last run total cost: ${self.last_run_result.total_cost_usd:.6f}',
|
||
]
|
||
)
|
||
if self.last_run_result.scratchpad_directory is not None:
|
||
lines.append(
|
||
f'- Scratchpad directory: {self.last_run_result.scratchpad_directory}'
|
||
)
|
||
else:
|
||
lines.append('- Last run: none')
|
||
if self.agent_manager is not None:
|
||
lines.extend(self.agent_manager.summary_lines())
|
||
return '\n'.join(lines)
|
||
|
||
def render_token_budget_report(self) -> str:
|
||
session = self.last_session or self.build_session()
|
||
snapshot = calculate_token_budget(
|
||
session=session,
|
||
model=self.model_config.model,
|
||
budget_config=self.runtime_config.budget_config,
|
||
output_schema=self.runtime_config.output_schema,
|
||
)
|
||
return format_token_budget(snapshot)
|
||
|
||
def _finalize_managed_agent(self, result: AgentRunResult) -> None:
|
||
if self.managed_agent_id is None or self.agent_manager is None:
|
||
self.resume_source_session_id = None
|
||
return
|
||
self.agent_manager.finish_agent(
|
||
self.managed_agent_id,
|
||
session_id=result.session_id,
|
||
session_path=result.session_path,
|
||
turns=result.turns,
|
||
tool_calls=result.tool_calls,
|
||
stop_reason=result.stop_reason,
|
||
)
|
||
self.resume_source_session_id = None
|
||
|
||
def _accumulate_usage(self, result: AgentRunResult) -> None:
|
||
"""Add a run's usage to the cumulative session totals."""
|
||
self.cumulative_usage = self.cumulative_usage + result.usage
|
||
self.cumulative_cost_usd += result.total_cost_usd
|
||
|
||
def _refresh_runtime_views_for_tool_result(
|
||
self,
|
||
tool_name: str,
|
||
tool_result: ToolExecutionResult,
|
||
) -> None:
|
||
if not tool_result.ok:
|
||
return
|
||
cwd_update = tool_result.metadata.get('cwd_update')
|
||
if isinstance(cwd_update, str) and cwd_update:
|
||
self._apply_runtime_cwd_update(Path(cwd_update))
|
||
refresh_tool_names = {
|
||
'update_plan',
|
||
'plan_clear',
|
||
'task_create',
|
||
'task_update',
|
||
'task_start',
|
||
'task_complete',
|
||
'task_block',
|
||
'task_cancel',
|
||
'todo_write',
|
||
'search_activate_provider',
|
||
'remote_connect',
|
||
'remote_disconnect',
|
||
'account_login',
|
||
'account_logout',
|
||
'config_set',
|
||
'ask_user_question',
|
||
'team_create',
|
||
'team_delete',
|
||
'send_message',
|
||
'workflow_run',
|
||
'remote_trigger',
|
||
'worktree_enter',
|
||
'worktree_exit',
|
||
}
|
||
if tool_name not in refresh_tool_names:
|
||
return
|
||
clear_context_caches()
|
||
additional_dirs = tuple(
|
||
str(path) for path in self.runtime_config.additional_working_directories
|
||
)
|
||
if tool_name.startswith('remote_'):
|
||
self.remote_runtime = RemoteRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name == 'remote_trigger':
|
||
self.remote_trigger_runtime = RemoteTriggerRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name.startswith('search_'):
|
||
self.search_runtime = SearchRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name.startswith('account_'):
|
||
self.account_runtime = AccountRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name == 'ask_user_question':
|
||
self.ask_user_runtime = AskUserRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name == 'config_set':
|
||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||
if tool_name.startswith('task_') or tool_name == 'todo_write':
|
||
self.task_runtime = TaskRuntime.from_storage_path(
|
||
(
|
||
self.task_runtime.storage_path
|
||
if self.task_runtime is not None
|
||
else self._session_task_runtime_path()
|
||
)
|
||
)
|
||
if tool_name.startswith('plan_') or tool_name == 'update_plan':
|
||
self.plan_runtime = PlanRuntime.from_storage_path(
|
||
(
|
||
self.plan_runtime.storage_path
|
||
if self.plan_runtime is not None
|
||
else self._session_plan_runtime_path()
|
||
)
|
||
)
|
||
if self.task_runtime is not None:
|
||
self.task_runtime = TaskRuntime.from_storage_path(
|
||
self.task_runtime.storage_path
|
||
)
|
||
if tool_name.startswith('team_') or tool_name == 'send_message':
|
||
self.team_runtime = TeamRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name.startswith('workflow_'):
|
||
self.workflow_runtime = WorkflowRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_working_directories=additional_dirs,
|
||
)
|
||
if tool_name.startswith('worktree_'):
|
||
self.worktree_runtime = WorktreeRuntime.from_workspace(self.runtime_config.cwd)
|
||
self.tool_context = replace(
|
||
self.tool_context,
|
||
tool_registry=self.tool_registry,
|
||
search_runtime=self.search_runtime,
|
||
account_runtime=self.account_runtime,
|
||
ask_user_runtime=self.ask_user_runtime,
|
||
config_runtime=self.config_runtime,
|
||
lsp_runtime=self.lsp_runtime,
|
||
remote_runtime=self.remote_runtime,
|
||
remote_trigger_runtime=self.remote_trigger_runtime,
|
||
plan_runtime=self.plan_runtime,
|
||
task_runtime=self.task_runtime,
|
||
team_runtime=self.team_runtime,
|
||
workflow_runtime=self.workflow_runtime,
|
||
worktree_runtime=self.worktree_runtime,
|
||
)
|
||
|
||
def _apply_runtime_cwd_update(self, new_cwd: Path) -> None:
|
||
resolved_cwd = new_cwd.resolve()
|
||
if resolved_cwd == self.runtime_config.cwd.resolve():
|
||
return
|
||
self.runtime_config = replace(self.runtime_config, cwd=resolved_cwd)
|
||
clear_context_caches()
|
||
additional_dirs = tuple(
|
||
str(path) for path in self.runtime_config.additional_working_directories
|
||
)
|
||
self.plugin_runtime = PluginRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.hook_policy_runtime = HookPolicyRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.mcp_runtime = MCPRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.remote_runtime = RemoteRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.remote_trigger_runtime = RemoteTriggerRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.search_runtime = SearchRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.account_runtime = AccountRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.ask_user_runtime = AskUserRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.config_runtime = ConfigRuntime.from_workspace(self.runtime_config.cwd)
|
||
self.lsp_runtime = LSPRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.task_runtime = TaskRuntime.from_storage_path(
|
||
(
|
||
self.task_runtime.storage_path
|
||
if self.task_runtime is not None
|
||
else self._session_task_runtime_path()
|
||
)
|
||
)
|
||
self.plan_runtime = PlanRuntime.from_storage_path(
|
||
(
|
||
self.plan_runtime.storage_path
|
||
if self.plan_runtime is not None
|
||
else self._session_plan_runtime_path()
|
||
)
|
||
)
|
||
self.team_runtime = TeamRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.workflow_runtime = WorkflowRuntime.from_workspace(
|
||
self.runtime_config.cwd,
|
||
additional_dirs,
|
||
)
|
||
self.worktree_runtime = WorktreeRuntime.from_workspace(self.runtime_config.cwd)
|
||
self.runtime_config = self._apply_hook_policy_budget_overrides(self.runtime_config)
|
||
registry = dict(default_tool_registry())
|
||
if self.plugin_runtime is not None:
|
||
alias_tools = self.plugin_runtime.register_tool_aliases(registry)
|
||
if alias_tools:
|
||
registry = {**registry, **alias_tools}
|
||
virtual_tools = self.plugin_runtime.register_virtual_tools(registry)
|
||
if virtual_tools:
|
||
registry = {**registry, **virtual_tools}
|
||
self.tool_registry = registry
|
||
self.tool_context = build_tool_context(
|
||
self.runtime_config,
|
||
tool_registry=self.tool_registry,
|
||
extra_env=(
|
||
self.hook_policy_runtime.safe_env()
|
||
if self.hook_policy_runtime is not None
|
||
else None
|
||
),
|
||
search_runtime=self.search_runtime,
|
||
account_runtime=self.account_runtime,
|
||
ask_user_runtime=self.ask_user_runtime,
|
||
config_runtime=self.config_runtime,
|
||
lsp_runtime=self.lsp_runtime,
|
||
mcp_runtime=self.mcp_runtime,
|
||
remote_runtime=self.remote_runtime,
|
||
remote_trigger_runtime=self.remote_trigger_runtime,
|
||
plan_runtime=self.plan_runtime,
|
||
task_runtime=self.task_runtime,
|
||
team_runtime=self.team_runtime,
|
||
workflow_runtime=self.workflow_runtime,
|
||
worktree_runtime=self.worktree_runtime,
|
||
)
|
||
|
||
def _apply_plugin_before_prompt_hooks(self, prompt: str) -> str:
|
||
if self.plugin_runtime is None:
|
||
return prompt
|
||
injections = self.plugin_runtime.before_prompt_injections()
|
||
state_reminder = self.plugin_runtime.runtime_state_reminder()
|
||
if not injections and not state_reminder:
|
||
return prompt
|
||
lines = ['<system-reminder>', 'Plugin before-prompt hooks:']
|
||
lines.extend(f'- {entry}' for entry in injections)
|
||
if state_reminder:
|
||
lines.extend(['', state_reminder])
|
||
lines.extend(['</system-reminder>', '', prompt])
|
||
return '\n'.join(lines)
|
||
|
||
def _apply_plugin_resume_hooks(
|
||
self,
|
||
prompt: str,
|
||
*,
|
||
resumed: bool,
|
||
) -> str:
|
||
if not resumed or self.plugin_runtime is None:
|
||
return prompt
|
||
injections = self.plugin_runtime.on_resume_injections()
|
||
if not injections:
|
||
return prompt
|
||
lines = ['<system-reminder>', 'Plugin resume hooks:']
|
||
lines.extend(f'- {entry}' for entry in injections)
|
||
lines.extend(['</system-reminder>', '', prompt])
|
||
return '\n'.join(lines)
|
||
|
||
def _render_plugin_persist_message(
|
||
self,
|
||
messages: tuple[str, ...],
|
||
) -> str:
|
||
lines = ['<system-reminder>', 'Plugin persist hooks:']
|
||
lines.extend(f'- {entry}' for entry in messages)
|
||
lines.extend(
|
||
[
|
||
'',
|
||
'This session state was persisted with plugin lifecycle guidance.',
|
||
'</system-reminder>',
|
||
]
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
def _append_plugin_after_turn_events(
|
||
self,
|
||
result: AgentRunResult,
|
||
*,
|
||
prompt: str,
|
||
turn_index: int,
|
||
) -> AgentRunResult:
|
||
if self.plugin_runtime is None:
|
||
return result
|
||
injections = self.plugin_runtime.after_turn_injections()
|
||
if not injections:
|
||
return result
|
||
appended = list(result.events)
|
||
for entry in injections:
|
||
appended.append(
|
||
{
|
||
'type': 'plugin_after_turn',
|
||
'turn_index': turn_index,
|
||
'message': entry,
|
||
'prompt_preview': self._preview_text(prompt, 120),
|
||
'stop_reason': result.stop_reason,
|
||
}
|
||
)
|
||
return replace(result, events=tuple(appended))
|
||
|
||
def _append_runtime_after_turn_events(
|
||
self,
|
||
result: AgentRunResult,
|
||
*,
|
||
prompt: str,
|
||
turn_index: int,
|
||
) -> AgentRunResult:
|
||
updated = self._append_plugin_after_turn_events(
|
||
result,
|
||
prompt=prompt,
|
||
turn_index=turn_index,
|
||
)
|
||
if self.hook_policy_runtime is None:
|
||
return updated
|
||
injections = self.hook_policy_runtime.after_turn_messages()
|
||
if not injections:
|
||
return updated
|
||
appended = list(updated.events)
|
||
for entry in injections:
|
||
appended.append(
|
||
{
|
||
'type': 'hook_policy_after_turn',
|
||
'turn_index': turn_index,
|
||
'message': entry,
|
||
'prompt_preview': self._preview_text(prompt, 120),
|
||
'stop_reason': updated.stop_reason,
|
||
'trusted': self.hook_policy_runtime.is_trusted(),
|
||
}
|
||
)
|
||
return replace(updated, events=tuple(appended))
|
||
|
||
|
||
def _optional_policy_int(value: object) -> int | None:
|
||
if isinstance(value, bool) or not isinstance(value, int):
|
||
return None
|
||
return value
|
||
|
||
|
||
def _optional_policy_float(value: object) -> float | None:
|
||
if isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
return None
|