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 import time 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, sanitize_model_message_sequence 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( [ '', runtime_context.strip(), '', 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 class _CombinedCancelEvent: """Expose a single cancel flag backed by run cancel plus per-tool interrupt.""" def __init__( self, run_cancel_event: Any | None, tool_interrupt_event: threading.Event, ) -> None: self._run_cancel_event = run_cancel_event self._tool_interrupt_event = tool_interrupt_event def is_set(self) -> bool: run_cancelled = ( self._run_cancel_event is not None and bool(self._run_cancel_event.is_set()) ) return run_cancelled or self._tool_interrupt_event.is_set() def set(self) -> None: self._tool_interrupt_event.set() def wait(self, timeout: float | None = None) -> bool: if self.is_set(): return True if self._run_cancel_event is None: return self._tool_interrupt_event.wait(timeout) if timeout is None: while not self.is_set(): self._tool_interrupt_event.wait(0.1) return True deadline = time.monotonic() + max(0.0, timeout) while not self.is_set(): remaining = deadline - time.monotonic() if remaining <= 0: return self.is_set() self._tool_interrupt_event.wait(min(0.1, remaining)) return True @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 runtime_guidance_provider: Callable[[], tuple[dict[str, object], ...]] | 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, display_messages=stored_session.display_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 = self._sanitize_persisted_tool_call_count( stored_resume_state.tool_calls, stored_resume_state.messages, stored_resume_state.display_messages, ) 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._inject_runtime_guidance( session, stream_events, turn_index=turn_index, stage='before_model', ) 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}', display=False, ) 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 turn.tool_calls: guidance_items = self._consume_runtime_guidance( stream_events, turn_index=turn_index, stage='before_tools', ) if guidance_items: self._append_skipped_tool_results_for_guidance( session, stream_events, turn=turn, turn_index=turn_index, guidance_items=guidance_items, ) self._append_runtime_guidance_items( session, stream_events, guidance_items, turn_index=turn_index, stage='before_tools', ) assistant_response_segments.clear() continuation_count = 0 last_content = turn.content continue 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}', display=False, ) stream_events.append( { 'type': 'continuation_request', 'reason': turn.finish_reason, 'continuation_index': continuation_count, } ) last_content = ''.join(assistant_response_segments) continue if self._inject_runtime_guidance( session, stream_events, turn_index=turn_index, stage='before_finish', ): assistant_response_segments.clear() continuation_count = 0 last_content = turn.content 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 replan_after_tool_guidance = False 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_guidance_items: tuple[dict[str, object], ...] = () tool_guidance_interrupted = False 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: tool_interrupt_event = threading.Event() tool_context = replace( self.tool_context, cancel_event=_CombinedCancelEvent( self.tool_context.cancel_event, tool_interrupt_event, ), ) for update in execute_tool_streaming( self.tool_registry, tool_call.name, tool_call.arguments, 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, } ) if not tool_guidance_items: pending_guidance = self._consume_runtime_guidance( stream_events, turn_index=turn_index, stage='during_tool', ) if pending_guidance: tool_guidance_items = pending_guidance if self._runtime_guidance_should_interrupt_tool( tool_call=tool_call, guidance_items=pending_guidance, ): tool_guidance_interrupted = True tool_interrupt_event.set() self._interrupt_running_tool_for_guidance( stream_events, turn_index=turn_index, tool_call=tool_call, guidance_items=pending_guidance, ) else: stream_events.append( { 'type': 'runtime_guidance_deferred_after_tool', 'turn_index': turn_index, 'tool_name': tool_call.name, 'tool_call_id': tool_call.id, 'input_ids': self._runtime_guidance_item_ids( pending_guidance ), 'message': ( '用户引导在工具执行中到达,运行时判断无需中断,' '将等待当前工具完成后重规划' ), } ) continue tool_result = update.result if tool_result is not None and not tool_guidance_items: tool_guidance_items = self._consume_runtime_guidance( stream_events, turn_index=turn_index, stage='after_tool', ) if tool_guidance_items: stream_events.append( { 'type': 'runtime_guidance_deferred_after_tool', 'turn_index': turn_index, 'tool_name': tool_call.name, 'tool_call_id': tool_call.id, 'input_ids': self._runtime_guidance_item_ids( tool_guidance_items ), 'message': ( '用户引导在工具执行期间到达,但当前工具未提供中间输出;' '已在工具完成后准备重规划' ), } ) if tool_result is not None and tool_guidance_items: merged_metadata = dict(tool_result.metadata) merged_metadata['runtime_guidance_input_ids'] = ( self._runtime_guidance_item_ids(tool_guidance_items) ) if tool_guidance_interrupted: merged_metadata['runtime_guidance_interrupted_tool'] = True else: merged_metadata['runtime_guidance_deferred_after_tool'] = True tool_result = ToolExecutionResult( name=tool_result.name, ok=tool_result.ok, content=tool_result.content, metadata=merged_metadata, ) 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}', display=False, ) 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_guidance_items: self._append_runtime_guidance_items( session, stream_events, tool_guidance_items, turn_index=turn_index, stage=( 'during_tool_interrupted' if tool_guidance_interrupted else 'after_tool' ), ) assistant_response_segments.clear() continuation_count = 0 last_content = turn.content replan_after_tool_guidance = True break 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 if replan_after_tool_guidance: continue 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, () request_messages = session.to_openai_messages() 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( request_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 ( '\n' 'Your previous answer was truncated because the model stopped early. ' 'Continue exactly where you left off. Do not repeat completed text.\n' '' ) 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 = sanitize_model_message_sequence( 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=len(preserved_tail), preserved_tail=preserved_tail, ) session.messages = ( session.messages[:prefix_count] + [compact_message] + preserved_tail ) 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': len(preserved_tail), '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('') ): 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 ( '\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' '' ) 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 = [ '', '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.', '', ] ) 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\n{agent_def.critical_system_reminder}\n' 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 = [ '', 'Prior delegated subtask summaries:', ] for result in prior_results[-4:]: lines.append(f"- {result['label']}: {result['output_preview']}") lines.extend(['', '', prompt]) return '\n'.join(lines) def _prepend_plugin_delegate_context( self, prompt: str, messages: tuple[str, ...], ) -> str: if not messages: return prompt lines = [ '', 'Plugin delegate guidance:', ] lines.extend(f'- {message}' for message in messages) lines.extend(['', '', 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}', display=False, ) 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 = [ '', '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.', '', ] ) 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)}' ), display=False, ) def _render_compaction_replay( self, compact_messages, snipped_messages, ) -> str: lines = [ '', '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.', '', ] ) 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 = ['', '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(['', '', 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 = [ '', ( 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.', '', ] ) 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}', display=False, ) 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 = self._sanitize_persisted_tool_call_count( previous.tool_calls, previous.messages, previous.display_messages, ) if isinstance(previous.budget_state, dict): previous_budget_state = dict(previous.budget_state) total_tool_calls = self._merge_tool_call_count( previous_tool_calls, result.tool_calls, ) budget_state = { 'model_calls': int(previous_budget_state.get('model_calls', 0)) + max(result.turns, 0), 'session_turns': previous_turns + result.turns, 'tool_calls': total_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.model_transcript(), display_messages=session.display_transcript(), turns=previous_turns + result.turns, tool_calls=total_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(), ) @staticmethod def _merge_tool_call_count(previous_tool_calls: int, result_tool_calls: int) -> int: previous = max(0, int(previous_tool_calls or 0)) current = max(0, int(result_tool_calls or 0)) # Resumed runs initialize the runtime counter from persisted state, so # result.tool_calls is usually already session-cumulative. Do not add it # to the previous total again. if current >= previous: return current # Some early-return paths still return a per-run delta. return previous + current @staticmethod def _sanitize_persisted_tool_call_count( persisted_tool_calls: int, model_messages: tuple[dict[str, object], ...], display_messages: tuple[dict[str, object], ...], ) -> int: persisted = max(0, int(persisted_tool_calls or 0)) counted = max( LocalCodingAgent._count_assistant_tool_calls(model_messages), LocalCodingAgent._count_assistant_tool_calls(display_messages), ) if counted and persisted > counted * 100: return counted return persisted @staticmethod def _count_assistant_tool_calls(messages: tuple[dict[str, object], ...]) -> int: total = 0 for message in messages: if not isinstance(message, dict) or message.get('role') != 'assistant': continue tool_calls = message.get('tool_calls') if isinstance(tool_calls, (list, tuple)): total += len(tool_calls) return total def _inject_runtime_guidance( self, session: AgentSessionState, stream_events: list[dict[str, object]], *, turn_index: int, stage: str, ) -> bool: items = self._consume_runtime_guidance( stream_events, turn_index=turn_index, stage=stage, ) if not items: return False self._append_runtime_guidance_items( session, stream_events, items, turn_index=turn_index, stage=stage, ) return True def _consume_runtime_guidance( self, stream_events: list[dict[str, object]], *, turn_index: int, stage: str, ) -> tuple[dict[str, object], ...]: provider = self.runtime_guidance_provider if provider is None: return () try: items = tuple( item for item in provider() if isinstance(item, dict) and str(item.get('content') or '').strip() ) except Exception as exc: # noqa: BLE001 stream_events.append( { 'type': 'runtime_guidance_error', 'turn_index': turn_index, 'stage': stage, 'error': str(exc)[:500], } ) return () return items def _append_runtime_guidance_items( self, session: AgentSessionState, stream_events: list[dict[str, object]], items: tuple[dict[str, object], ...], *, turn_index: int, stage: str, ) -> None: lines = [ '', ( '用户在本轮任务运行过程中追加了以下引导。请在不丢弃当前进展的前提下' '吸收这些要求;如果它们与当前目标冲突,先说明冲突并选择最合理的继续方式。' ), ] if stage == 'before_tools': lines.append( '这批引导在工具执行前到达,运行时已经跳过上一批尚未执行的工具调用。' '请重新判断:继续原计划、调整工具参数、改走新计划,或说明冲突。' ) elif stage == 'before_finish': lines.append( '这批引导在最终回复前到达。请判断它是补充最终输出、修正当前结论,' '还是应作为后续任务处理。' ) elif stage == 'during_tool_interrupted': lines.append( '这批引导在工具执行中到达,运行时已经中断当前工具并停止继续执行同批旧工具计划。' '请结合已有工具输出和这批引导重新规划;如果需要重跑工具,请使用新的参数。' ) elif stage == 'after_tool': lines.append( '这批引导在工具执行期间或刚完成后到达。当前工具结果已保留,' '运行时停止继续执行同批旧工具计划,请判断是否基于该结果继续、补充或重跑。' ) item_ids: list[int] = [] for index, item in enumerate(items, start=1): item_id = item.get('id') if isinstance(item_id, int) and not isinstance(item_id, bool): item_ids.append(item_id) content = str(item.get('content') or '').strip() lines.append(f'{index}. {content}') lines.append('') session.append_user( '\n'.join(lines), metadata={ 'kind': 'runtime_guidance', 'turn_index': turn_index, 'stage': stage, 'queue_item_ids': item_ids, }, message_id=f'runtime_guidance_{turn_index}_{uuid4().hex[:8]}', display=False, ) stream_events.append( { 'type': 'runtime_guidance_injected', 'turn_index': turn_index, 'stage': stage, 'input_ids': item_ids, 'count': len(items), 'message': f'已将 {len(items)} 条用户引导注入当前任务', } ) @staticmethod def _runtime_guidance_item_ids( items: tuple[dict[str, object], ...], ) -> list[int]: return [ int(item.get('id')) for item in items if isinstance(item.get('id'), int) and not isinstance(item.get('id'), bool) ] @staticmethod def _runtime_guidance_should_interrupt_tool( *, tool_call: ToolCall, guidance_items: tuple[dict[str, object], ...], ) -> bool: combined = '\n'.join( str(item.get('content') or '').strip().lower() for item in guidance_items ) if not combined: return False defer_markers = ( '完成后', '结束后', '最后', '顺便', '同时整理', '补充输出', 'finish', 'after', 'then', 'also', ) interrupt_markers = ( '停', '中断', '取消', '暂停', '别跑', '别继续', '不要继续', '先别', '错了', '不对', '改成', '换成', '重新', '重来', '不用', '不是', '别用', 'stop', 'cancel', 'interrupt', 'abort', 'wrong', 'instead', 'change to', 'switch to', 'restart', 'redo', ) if any(marker in combined for marker in interrupt_markers): return True if any(marker in combined for marker in defer_markers): return False return tool_call.name in { 'bash', 'python_exec', 'jupyter_exec', 'remote_bash', 'remote_python_exec', 'Agent', 'delegate_agent', } and len(combined) >= 120 def _interrupt_running_tool_for_guidance( self, stream_events: list[dict[str, object]], *, turn_index: int, tool_call: ToolCall, guidance_items: tuple[dict[str, object], ...], ) -> None: process_registry = self.tool_context.process_registry killed_processes = False if process_registry is not None: try: process_registry.kill_all() killed_processes = True except Exception as exc: # noqa: BLE001 stream_events.append( { 'type': 'runtime_guidance_error', 'turn_index': turn_index, 'stage': 'during_tool', 'tool_name': tool_call.name, 'tool_call_id': tool_call.id, 'error': str(exc)[:500], } ) stream_events.append( { 'type': 'runtime_guidance_interrupt_tool', 'turn_index': turn_index, 'tool_name': tool_call.name, 'tool_call_id': tool_call.id, 'input_ids': self._runtime_guidance_item_ids(guidance_items), 'killed_processes': killed_processes, 'message': '用户引导要求调整当前任务,已中断当前工具并准备重规划', } ) def _append_skipped_tool_results_for_guidance( self, session: AgentSessionState, stream_events: list[dict[str, object]], *, turn: AssistantTurn, turn_index: int, guidance_items: tuple[dict[str, object], ...], ) -> None: input_ids = [ int(item.get('id')) for item in guidance_items if isinstance(item.get('id'), int) and not isinstance(item.get('id'), bool) ] for tool_call in turn.tool_calls: tool_result = ToolExecutionResult( name=tool_call.name, ok=False, content=( '工具尚未执行时收到了用户运行中引导。运行时跳过了这次旧工具调用,' '请结合后续 runtime guidance 重新规划。' ), metadata={ 'action': 'runtime_guidance_replan', 'runtime_guidance_skipped': True, 'runtime_guidance_input_ids': input_ids, }, ) session.append_tool( tool_call.name, tool_call.id, serialize_tool_result(tool_result), display=False, ) stream_events.append( { 'type': 'runtime_guidance_replan_before_tools', 'turn_index': turn_index, 'input_ids': input_ids, 'tool_call_count': len(turn.tool_calls), 'tool_names': [tool_call.name for tool_call in turn.tool_calls], 'message': '用户引导在工具执行前到达,已跳过旧工具计划并要求模型重规划', } ) 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 = ['', 'Plugin before-prompt hooks:'] lines.extend(f'- {entry}' for entry in injections) if state_reminder: lines.extend(['', state_reminder]) lines.extend(['', '', 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 = ['', 'Plugin resume hooks:'] lines.extend(f'- {entry}' for entry in injections) lines.extend(['', '', prompt]) return '\n'.join(lines) def _render_plugin_persist_message( self, messages: tuple[str, ...], ) -> str: lines = ['', 'Plugin persist hooks:'] lines.extend(f'- {entry}' for entry in messages) lines.extend( [ '', 'This session state was persisted with plugin lifecycle guidance.', '', ] ) 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