add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/background_runtime.py. The agent now has a real
persistent plan runtime with update_plan, plan_get, and plan_clear, plus plan-to-task sync wired through claw-code-agent/src/ agent_tools.py, claw-code-agent/src/agent_runtime.py, claw-code-agent/src/agent_context.py, claw-code-agent/src/agent_prompting.py, and claw-code-agent/src/ agent_slash_commands.py. New local plan slash commands are /plan and /planner.
This commit is contained in:
@@ -13,14 +13,19 @@ from .agent_runtime import LocalCodingAgent
|
||||
from .agent_session import AgentMessage, AgentSessionState
|
||||
from .agent_tools import build_tool_context, default_tool_registry, execute_tool
|
||||
from .agent_types import AgentPermissions, AgentRunResult, AgentRuntimeConfig, ModelConfig
|
||||
from .background_runtime import BackgroundSessionRuntime
|
||||
from .commands import PORTED_COMMANDS, build_command_backlog
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .parity_audit import ParityAuditResult, run_parity_audit
|
||||
from .plan_runtime import PlanRuntime, PlanStep
|
||||
from .plugin_runtime import PluginRuntime
|
||||
from .port_manifest import PortManifest, build_port_manifest
|
||||
from .query_engine import QueryEnginePort, TurnResult
|
||||
from .runtime import PortRuntime, RuntimeSession
|
||||
from .session_store import StoredSession, load_session, save_session
|
||||
from .system_init import build_system_init_message
|
||||
from .task import PortingTask
|
||||
from .task_runtime import TaskRuntime
|
||||
from .tools import PORTED_TOOLS, build_tool_backlog
|
||||
|
||||
__all__ = [
|
||||
@@ -31,15 +36,21 @@ __all__ = [
|
||||
'AgentRuntimeConfig',
|
||||
'AgentMessage',
|
||||
'AgentSessionState',
|
||||
'BackgroundSessionRuntime',
|
||||
'LocalCodingAgent',
|
||||
'MCPRuntime',
|
||||
'ModelConfig',
|
||||
'ParityAuditResult',
|
||||
'PlanRuntime',
|
||||
'PlanStep',
|
||||
'PortManifest',
|
||||
'PortRuntime',
|
||||
'PluginRuntime',
|
||||
'PortingTask',
|
||||
'QueryEnginePort',
|
||||
'RuntimeSession',
|
||||
'StoredSession',
|
||||
'TaskRuntime',
|
||||
'TurnResult',
|
||||
'PORTED_COMMANDS',
|
||||
'PORTED_TOOLS',
|
||||
|
||||
@@ -9,7 +9,11 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from .agent_plugin_cache import load_plugin_cache_summary
|
||||
from .hook_policy import HookPolicyRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .plan_runtime import PlanRuntime
|
||||
from .plugin_runtime import PluginRuntime
|
||||
from .task_runtime import TaskRuntime
|
||||
from .agent_types import AgentRuntimeConfig
|
||||
|
||||
MAX_STATUS_CHARS = 2000
|
||||
@@ -197,6 +201,35 @@ def _get_user_context_cached(
|
||||
plugin_runtime = PluginRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if plugin_runtime.manifests:
|
||||
context['pluginRuntime'] = plugin_runtime.render_summary()
|
||||
hook_policy_runtime = HookPolicyRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if hook_policy_runtime.manifests:
|
||||
context['hookPolicy'] = hook_policy_runtime.render_summary()
|
||||
managed_settings = hook_policy_runtime.managed_settings()
|
||||
if managed_settings:
|
||||
context['managedSettings'] = '\n'.join(
|
||||
f'{key}={value}'
|
||||
for key, value in sorted(managed_settings.items())
|
||||
)
|
||||
safe_env = hook_policy_runtime.safe_env()
|
||||
if safe_env:
|
||||
context['safeEnv'] = '\n'.join(
|
||||
f'{key}={value}'
|
||||
for key, value in sorted(safe_env.items())
|
||||
)
|
||||
context['trustMode'] = (
|
||||
'Workspace trust mode: trusted'
|
||||
if hook_policy_runtime.is_trusted()
|
||||
else 'Workspace trust mode: untrusted'
|
||||
)
|
||||
mcp_runtime = MCPRuntime.from_workspace(Path(cwd), additional_working_directories)
|
||||
if mcp_runtime.resources:
|
||||
context['mcpRuntime'] = mcp_runtime.render_summary()
|
||||
plan_runtime = PlanRuntime.from_workspace(Path(cwd))
|
||||
if plan_runtime.steps:
|
||||
context['planRuntime'] = plan_runtime.render_summary()
|
||||
task_runtime = TaskRuntime.from_workspace(Path(cwd))
|
||||
if task_runtime.tasks:
|
||||
context['taskRuntime'] = task_runtime.render_summary()
|
||||
return context
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ def build_system_prompt_parts(
|
||||
get_actions_section(),
|
||||
get_using_your_tools_section(enabled_tool_names),
|
||||
get_plugin_guidance_section(prompt_context),
|
||||
get_mcp_guidance_section(prompt_context),
|
||||
get_plan_guidance_section(prompt_context),
|
||||
get_task_guidance_section(prompt_context),
|
||||
get_hook_policy_guidance_section(prompt_context),
|
||||
get_tone_and_style_section(),
|
||||
get_output_efficiency_section(),
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
@@ -207,6 +211,56 @@ def get_plugin_guidance_section(prompt_context: PromptContext) -> str:
|
||||
return '\n'.join(['# Plugins', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_hook_policy_guidance_section(prompt_context: PromptContext) -> str:
|
||||
hook_policy = prompt_context.user_context.get('hookPolicy')
|
||||
trust_mode = prompt_context.user_context.get('trustMode')
|
||||
if not hook_policy and not trust_mode:
|
||||
return ''
|
||||
items = [
|
||||
'Workspace hook and policy manifests may inject trust mode, safe environment values, tool deny rules, and managed settings.',
|
||||
'Treat workspace trust mode as high-priority local runtime guidance when deciding whether to edit files or run shell commands.',
|
||||
'If a workspace policy blocks a tool, do not retry it unchanged. Change approach or explain the limitation.',
|
||||
]
|
||||
return '\n'.join(['# Hook Policy', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_mcp_guidance_section(prompt_context: PromptContext) -> str:
|
||||
mcp_runtime = prompt_context.user_context.get('mcpRuntime')
|
||||
if not mcp_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'Local MCP manifests may expose additional resources through the runtime.',
|
||||
'Use MCP resource tools when the task depends on manifest-backed external context or curated workspace resources.',
|
||||
'Treat MCP resource summaries as discoverability hints and prefer reading the specific resource URI before relying on its contents.',
|
||||
]
|
||||
return '\n'.join(['# MCP', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_task_guidance_section(prompt_context: PromptContext) -> str:
|
||||
task_runtime = prompt_context.user_context.get('taskRuntime')
|
||||
if not task_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'A local runtime task list may be available to track ongoing work.',
|
||||
'Use task and todo tools to keep the plan state current when the task spans multiple steps or files.',
|
||||
'Prefer updating the stored task list instead of repeating the same progress summary in free-form text.',
|
||||
]
|
||||
return '\n'.join(['# Tasks', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_plan_guidance_section(prompt_context: PromptContext) -> str:
|
||||
plan_runtime = prompt_context.user_context.get('planRuntime')
|
||||
if not plan_runtime:
|
||||
return ''
|
||||
items = [
|
||||
'A local runtime plan may be available to track the active multi-step workflow.',
|
||||
'Use the update_plan tool to keep the stored plan current when the task spans multiple milestones.',
|
||||
'When the plan changes materially, update the stored plan rather than relying only on free-form progress text.',
|
||||
'Plan updates can sync into the local task runtime, so keep step statuses accurate.',
|
||||
]
|
||||
return '\n'.join(['# Planning', *prepend_bullets(items)])
|
||||
|
||||
|
||||
def get_output_efficiency_section() -> str:
|
||||
return """# Communicating with the user
|
||||
|
||||
|
||||
+386
-16
@@ -10,6 +10,8 @@ from uuid import uuid4
|
||||
from .agent_manager import AgentManager
|
||||
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 .hook_policy import HookPolicyRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .agent_prompting import (
|
||||
build_prompt_context,
|
||||
build_system_prompt_parts,
|
||||
@@ -38,7 +40,9 @@ from .agent_types import (
|
||||
UsageStats,
|
||||
)
|
||||
from .openai_compat import OpenAICompatClient, OpenAICompatError
|
||||
from .plan_runtime import PlanRuntime
|
||||
from .plugin_runtime import PluginRuntime
|
||||
from .task_runtime import TaskRuntime
|
||||
from .session_store import (
|
||||
StoredAgentSession,
|
||||
load_agent_session,
|
||||
@@ -69,6 +73,10 @@ class LocalCodingAgent:
|
||||
managed_child_index: int | None = None
|
||||
managed_label: str | None = None
|
||||
plugin_runtime: PluginRuntime | None = None
|
||||
hook_policy_runtime: HookPolicyRuntime | None = None
|
||||
mcp_runtime: MCPRuntime | None = None
|
||||
plan_runtime: PlanRuntime | None = None
|
||||
task_runtime: TaskRuntime | 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)
|
||||
active_session_id: str | None = field(default=None, init=False, repr=False)
|
||||
@@ -86,6 +94,21 @@ class LocalCodingAgent:
|
||||
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.plan_runtime is None:
|
||||
self.plan_runtime = PlanRuntime.from_workspace(self.runtime_config.cwd)
|
||||
if self.task_runtime is None:
|
||||
self.task_runtime = TaskRuntime.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:
|
||||
@@ -95,7 +118,17 @@ class LocalCodingAgent:
|
||||
registry = {**registry, **virtual_tools}
|
||||
self.tool_registry = registry
|
||||
self.client = OpenAICompatClient(self.model_config)
|
||||
self.tool_context = build_tool_context(self.runtime_config)
|
||||
self.tool_context = build_tool_context(
|
||||
self.runtime_config,
|
||||
extra_env=(
|
||||
self.hook_policy_runtime.safe_env()
|
||||
if self.hook_policy_runtime is not None
|
||||
else None
|
||||
),
|
||||
mcp_runtime=self.mcp_runtime,
|
||||
plan_runtime=self.plan_runtime,
|
||||
task_runtime=self.task_runtime,
|
||||
)
|
||||
|
||||
def set_model(self, model: str) -> None:
|
||||
self.model_config = replace(self.model_config, model=model)
|
||||
@@ -144,6 +177,67 @@ class LocalCodingAgent:
|
||||
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) -> AgentRunResult:
|
||||
self.managed_agent_id = None
|
||||
self.resume_source_session_id = None
|
||||
@@ -220,7 +314,10 @@ class LocalCodingAgent:
|
||||
),
|
||||
)
|
||||
|
||||
effective_prompt = self._apply_plugin_before_prompt_hooks(slash_result.prompt or prompt)
|
||||
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,
|
||||
@@ -431,7 +528,7 @@ class LocalCodingAgent:
|
||||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||||
),
|
||||
)
|
||||
result = self._append_plugin_after_turn_events(
|
||||
result = self._append_runtime_after_turn_events(
|
||||
result,
|
||||
prompt=effective_prompt,
|
||||
turn_index=turn_index,
|
||||
@@ -512,7 +609,7 @@ class LocalCodingAgent:
|
||||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||||
),
|
||||
)
|
||||
result = self._append_plugin_after_turn_events(
|
||||
result = self._append_runtime_after_turn_events(
|
||||
result,
|
||||
prompt=effective_prompt,
|
||||
turn_index=turn_index,
|
||||
@@ -583,6 +680,9 @@ class LocalCodingAgent:
|
||||
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(
|
||||
{
|
||||
@@ -593,7 +693,18 @@ class LocalCodingAgent:
|
||||
'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(
|
||||
@@ -621,6 +732,27 @@ class LocalCodingAgent:
|
||||
'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 == 'delegate_agent':
|
||||
if tool_result is None:
|
||||
tool_result = self._execute_delegate_agent(tool_call.arguments)
|
||||
@@ -658,6 +790,7 @@ class LocalCodingAgent:
|
||||
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)
|
||||
@@ -677,12 +810,47 @@ class LocalCodingAgent:
|
||||
'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',
|
||||
@@ -707,6 +875,9 @@ class LocalCodingAgent:
|
||||
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(
|
||||
@@ -774,7 +945,7 @@ class LocalCodingAgent:
|
||||
str(scratchpad_directory) if scratchpad_directory is not None else None
|
||||
),
|
||||
)
|
||||
result = self._append_plugin_after_turn_events(
|
||||
result = self._append_runtime_after_turn_events(
|
||||
result,
|
||||
prompt=effective_prompt,
|
||||
turn_index=self.runtime_config.max_turns,
|
||||
@@ -2381,6 +2552,39 @@ class LocalCodingAgent:
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _apply_hook_policy_before_prompt_hooks(self, prompt: str) -> str:
|
||||
if self.hook_policy_runtime is None or not self.hook_policy_runtime.manifests:
|
||||
return prompt
|
||||
injections = self.hook_policy_runtime.before_prompt_messages()
|
||||
managed_settings = self.hook_policy_runtime.managed_settings()
|
||||
safe_env = self.hook_policy_runtime.safe_env()
|
||||
trusted = self.hook_policy_runtime.is_trusted()
|
||||
if not injections and not managed_settings and not safe_env and trusted:
|
||||
return prompt
|
||||
lines = ['<system-reminder>', 'Workspace hook/policy guidance:']
|
||||
lines.append(
|
||||
f'- Trust mode: {"trusted" if trusted else "untrusted"}'
|
||||
)
|
||||
if not trusted:
|
||||
lines.append(
|
||||
'- Untrusted workspaces should favor inspection-first behavior. '
|
||||
'Avoid unnecessary writes or shell actions unless the task clearly requires them.'
|
||||
)
|
||||
for entry in injections:
|
||||
lines.append(f'- Before prompt: {entry}')
|
||||
if managed_settings:
|
||||
lines.append(
|
||||
'- Managed settings: '
|
||||
+ ', '.join(f'{key}={value}' for key, value in sorted(managed_settings.items()))
|
||||
)
|
||||
if safe_env:
|
||||
lines.append(
|
||||
'- Safe environment values loaded for tools: '
|
||||
+ ', '.join(sorted(safe_env))
|
||||
)
|
||||
lines.extend(['</system-reminder>', '', prompt])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _build_plugin_tool_runtime_message(
|
||||
self,
|
||||
*,
|
||||
@@ -2388,6 +2592,9 @@ class LocalCodingAgent:
|
||||
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:
|
||||
@@ -2395,28 +2602,46 @@ class LocalCodingAgent:
|
||||
block_message is None
|
||||
and not plugin_messages
|
||||
and not preflight_messages
|
||||
and hook_policy_block_message is None
|
||||
and not hook_policy_preflight_messages
|
||||
and not hook_policy_messages
|
||||
and not delegate_preflight_messages
|
||||
and not delegate_after_messages
|
||||
):
|
||||
return None
|
||||
plugin_only = (
|
||||
hook_policy_block_message is None
|
||||
and not hook_policy_preflight_messages
|
||||
and not hook_policy_messages
|
||||
)
|
||||
lines = [
|
||||
'<system-reminder>',
|
||||
f'Plugin tool runtime guidance for `{tool_name}`:',
|
||||
(
|
||||
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 plugin guidance when deciding the next tool call or assistant response.',
|
||||
'Use this runtime guidance when deciding the next tool call or assistant response.',
|
||||
'</system-reminder>',
|
||||
]
|
||||
)
|
||||
@@ -2437,6 +2662,21 @@ class LocalCodingAgent:
|
||||
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,
|
||||
@@ -2546,15 +2786,27 @@ class LocalCodingAgent:
|
||||
|
||||
def render_permissions_report(self) -> str:
|
||||
permissions = self.runtime_config.permissions
|
||||
return '\n'.join(
|
||||
[
|
||||
'# 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"}',
|
||||
]
|
||||
)
|
||||
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
|
||||
@@ -2565,6 +2817,11 @@ class LocalCodingAgent:
|
||||
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)
|
||||
|
||||
@@ -2575,6 +2832,64 @@ class LocalCodingAgent:
|
||||
return '# Memory\n\nNo CLAUDE.md memory files are currently loaded.'
|
||||
return '\n'.join(['# Memory', '', claude_md])
|
||||
|
||||
def render_mcp_report(self, query: str | None = None) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP\n\nNo local MCP manifests 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_mcp_resources_report(self, query: str | None = None) -> str:
|
||||
if self.mcp_runtime is None:
|
||||
return '# MCP Resources\n\nNo local MCP manifests 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 or resources discovered.'
|
||||
return self.mcp_runtime.render_resource(uri)
|
||||
|
||||
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_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_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:
|
||||
lines = [
|
||||
'# Status',
|
||||
@@ -2585,6 +2900,16 @@ class LocalCodingAgent:
|
||||
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.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 and self.mcp_runtime.resources:
|
||||
lines.append(f'- MCP resources: {len(self.mcp_runtime.resources)}')
|
||||
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.last_session_path is not None:
|
||||
lines.append(f'- Session path: {self.last_session_path}')
|
||||
if self.last_run_result is not None:
|
||||
@@ -2689,3 +3014,48 @@ class LocalCodingAgent:
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
@@ -77,16 +77,16 @@ def preprocess_slash_command(
|
||||
'Commands are in the form `/command [args]`.',
|
||||
)
|
||||
|
||||
if parsed.is_mcp:
|
||||
return _local_result(
|
||||
input_text,
|
||||
'MCP slash commands are not implemented in the Python runtime yet.',
|
||||
)
|
||||
|
||||
spec = find_slash_command(parsed.command_name)
|
||||
normalized_name = (
|
||||
parsed.command_name[:-6]
|
||||
if parsed.is_mcp and parsed.command_name.endswith(' (MCP)')
|
||||
else parsed.command_name
|
||||
)
|
||||
spec = find_slash_command(normalized_name)
|
||||
if spec is None:
|
||||
if looks_like_command(parsed.command_name):
|
||||
return _local_result(input_text, f'Unknown skill: {parsed.command_name}')
|
||||
label = normalized_name if parsed.is_mcp else parsed.command_name
|
||||
return _local_result(input_text, f'Unknown skill: {label}')
|
||||
return SlashCommandResult(handled=False, should_query=True, prompt=input_text)
|
||||
|
||||
return spec.handler(agent, parsed.args.strip(), input_text)
|
||||
@@ -109,6 +109,36 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
||||
description='Show the raw environment, user context, and system context snapshot.',
|
||||
handler=_handle_context_raw,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('mcp',),
|
||||
description='Show discovered local MCP manifests and resource counts.',
|
||||
handler=_handle_mcp,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('resources',),
|
||||
description='List local MCP resources, optionally filtered by a query string.',
|
||||
handler=_handle_resources,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('resource',),
|
||||
description='Render a local MCP resource by URI.',
|
||||
handler=_handle_resource,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('tasks', 'todo'),
|
||||
description='Show the local runtime task list, optionally filtered by status.',
|
||||
handler=_handle_tasks,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('plan', 'planner'),
|
||||
description='Show the current local runtime plan.',
|
||||
handler=_handle_plan,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('task',),
|
||||
description='Show a local runtime task by id.',
|
||||
handler=_handle_task,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('prompt', 'system-prompt'),
|
||||
description='Render the effective Python system prompt.',
|
||||
@@ -119,6 +149,16 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
|
||||
description='Show the active tool permission mode.',
|
||||
handler=_handle_permissions,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('hooks', 'policy'),
|
||||
description='Show discovered local hook and policy manifests.',
|
||||
handler=_handle_hooks,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('trust',),
|
||||
description='Show workspace trust mode, managed settings, and safe environment values.',
|
||||
handler=_handle_trust,
|
||||
),
|
||||
SlashCommandSpec(
|
||||
names=('model',),
|
||||
description='Show or update the active model for the current agent instance.',
|
||||
@@ -180,6 +220,37 @@ def _handle_context_raw(agent: 'LocalCodingAgent', _args: str, input_text: str)
|
||||
return _local_result(input_text, agent.render_context_snapshot_report())
|
||||
|
||||
|
||||
def _handle_mcp(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
query = args or None
|
||||
return _local_result(input_text, agent.render_mcp_report(query))
|
||||
|
||||
|
||||
def _handle_resources(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
query = args or None
|
||||
return _local_result(input_text, agent.render_mcp_resources_report(query))
|
||||
|
||||
|
||||
def _handle_resource(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, 'Usage: /resource <mcp-resource-uri>')
|
||||
return _local_result(input_text, agent.render_mcp_resource_report(args))
|
||||
|
||||
|
||||
def _handle_tasks(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
status = args or None
|
||||
return _local_result(input_text, agent.render_tasks_report(status))
|
||||
|
||||
|
||||
def _handle_plan(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_plan_report())
|
||||
|
||||
|
||||
def _handle_task(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, 'Usage: /task <task-id>')
|
||||
return _local_result(input_text, agent.render_task_report(args))
|
||||
|
||||
|
||||
def _handle_prompt(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_system_prompt())
|
||||
|
||||
@@ -188,6 +259,14 @@ def _handle_permissions(agent: 'LocalCodingAgent', _args: str, input_text: str)
|
||||
return _local_result(input_text, agent.render_permissions_report())
|
||||
|
||||
|
||||
def _handle_hooks(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_hook_policy_report())
|
||||
|
||||
|
||||
def _handle_trust(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult:
|
||||
return _local_result(input_text, agent.render_trust_report())
|
||||
|
||||
|
||||
def _handle_model(agent: 'LocalCodingAgent', args: str, input_text: str) -> SlashCommandResult:
|
||||
if not args:
|
||||
return _local_result(input_text, f'Current model: {agent.model_config.model}')
|
||||
|
||||
+485
-4
@@ -2,16 +2,22 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import selectors
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Union
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Union
|
||||
|
||||
from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .plan_runtime import PlanRuntime
|
||||
from .task_runtime import TaskRuntime
|
||||
|
||||
|
||||
class ToolPermissionError(RuntimeError):
|
||||
"""Raised when the runtime configuration does not allow a tool action."""
|
||||
@@ -27,6 +33,10 @@ class ToolExecutionContext:
|
||||
command_timeout_seconds: float
|
||||
max_output_chars: int
|
||||
permissions: AgentPermissions
|
||||
extra_env: dict[str, str] = field(default_factory=dict)
|
||||
mcp_runtime: 'MCPRuntime | None' = None
|
||||
plan_runtime: 'PlanRuntime | None' = None
|
||||
task_runtime: 'TaskRuntime | None' = None
|
||||
|
||||
|
||||
ToolHandler = Callable[
|
||||
@@ -60,8 +70,20 @@ class AgentTool:
|
||||
else:
|
||||
content, metadata = result, {}
|
||||
return ToolExecutionResult(name=self.name, ok=True, content=content, metadata=metadata)
|
||||
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
||||
return ToolExecutionResult(name=self.name, ok=False, content=str(exc))
|
||||
except ToolPermissionError as exc:
|
||||
return ToolExecutionResult(
|
||||
name=self.name,
|
||||
ok=False,
|
||||
content=str(exc),
|
||||
metadata={'error_kind': 'permission_denied'},
|
||||
)
|
||||
except (ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
||||
return ToolExecutionResult(
|
||||
name=self.name,
|
||||
ok=False,
|
||||
content=str(exc),
|
||||
metadata={'error_kind': 'tool_execution_error'},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -73,12 +95,23 @@ class ToolStreamUpdate:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def build_tool_context(config: AgentRuntimeConfig) -> ToolExecutionContext:
|
||||
def build_tool_context(
|
||||
config: AgentRuntimeConfig,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
mcp_runtime: 'MCPRuntime | None' = None,
|
||||
plan_runtime: 'PlanRuntime | None' = None,
|
||||
task_runtime: 'TaskRuntime | None' = None,
|
||||
) -> ToolExecutionContext:
|
||||
return ToolExecutionContext(
|
||||
root=config.cwd.resolve(),
|
||||
command_timeout_seconds=config.command_timeout_seconds,
|
||||
max_output_chars=config.max_output_chars,
|
||||
permissions=config.permissions,
|
||||
extra_env=dict(extra_env or {}),
|
||||
mcp_runtime=mcp_runtime,
|
||||
plan_runtime=plan_runtime,
|
||||
task_runtime=task_runtime,
|
||||
)
|
||||
|
||||
|
||||
@@ -222,6 +255,159 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
},
|
||||
handler=_run_bash,
|
||||
),
|
||||
AgentTool(
|
||||
name='mcp_list_resources',
|
||||
description='List local MCP resources discovered from workspace MCP manifests.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'query': {'type': 'string'},
|
||||
'max_resources': {'type': 'integer', 'minimum': 1, 'maximum': 200},
|
||||
},
|
||||
},
|
||||
handler=_mcp_list_resources,
|
||||
),
|
||||
AgentTool(
|
||||
name='mcp_read_resource',
|
||||
description='Read a local MCP resource by URI from workspace MCP manifests.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'uri': {'type': 'string'},
|
||||
'max_chars': {'type': 'integer', 'minimum': 1, 'maximum': 50000},
|
||||
},
|
||||
'required': ['uri'],
|
||||
},
|
||||
handler=_mcp_read_resource,
|
||||
),
|
||||
AgentTool(
|
||||
name='plan_get',
|
||||
description='Show the current local runtime plan.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
},
|
||||
handler=_plan_get,
|
||||
),
|
||||
AgentTool(
|
||||
name='update_plan',
|
||||
description='Replace the current local runtime plan with a structured multi-step plan and optionally sync it to tasks.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'explanation': {'type': 'string'},
|
||||
'sync_tasks': {'type': 'boolean'},
|
||||
'items': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'step': {'type': 'string'},
|
||||
'status': {'type': 'string'},
|
||||
'task_id': {'type': 'string'},
|
||||
'description': {'type': 'string'},
|
||||
'priority': {'type': 'string'},
|
||||
},
|
||||
'required': ['step'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'required': ['items'],
|
||||
},
|
||||
handler=_update_plan,
|
||||
),
|
||||
AgentTool(
|
||||
name='plan_clear',
|
||||
description='Clear the current local runtime plan and optionally sync the task runtime.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'sync_tasks': {'type': 'boolean'},
|
||||
},
|
||||
},
|
||||
handler=_plan_clear,
|
||||
),
|
||||
AgentTool(
|
||||
name='task_list',
|
||||
description='List locally stored runtime tasks.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'status': {'type': 'string'},
|
||||
'max_tasks': {'type': 'integer', 'minimum': 1, 'maximum': 200},
|
||||
},
|
||||
},
|
||||
handler=_task_list,
|
||||
),
|
||||
AgentTool(
|
||||
name='task_get',
|
||||
description='Show a locally stored runtime task by id.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {'type': 'string'},
|
||||
},
|
||||
'required': ['task_id'],
|
||||
},
|
||||
handler=_task_get,
|
||||
),
|
||||
AgentTool(
|
||||
name='task_create',
|
||||
description='Create a locally stored runtime task.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'title': {'type': 'string'},
|
||||
'description': {'type': 'string'},
|
||||
'status': {'type': 'string'},
|
||||
'priority': {'type': 'string'},
|
||||
'task_id': {'type': 'string'},
|
||||
},
|
||||
'required': ['title'],
|
||||
},
|
||||
handler=_task_create,
|
||||
),
|
||||
AgentTool(
|
||||
name='task_update',
|
||||
description='Update a locally stored runtime task by id.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {'type': 'string'},
|
||||
'title': {'type': 'string'},
|
||||
'description': {'type': 'string'},
|
||||
'status': {'type': 'string'},
|
||||
'priority': {'type': 'string'},
|
||||
},
|
||||
'required': ['task_id'],
|
||||
},
|
||||
handler=_task_update,
|
||||
),
|
||||
AgentTool(
|
||||
name='todo_write',
|
||||
description='Replace the current local runtime task list with a structured todo list.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'items': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'task_id': {'type': 'string'},
|
||||
'title': {'type': 'string'},
|
||||
'description': {'type': 'string'},
|
||||
'status': {'type': 'string'},
|
||||
'priority': {'type': 'string'},
|
||||
},
|
||||
'required': ['title'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'required': ['items'],
|
||||
},
|
||||
handler=_todo_write,
|
||||
),
|
||||
AgentTool(
|
||||
name='delegate_agent',
|
||||
description='Delegate a subtask to a nested Python coding agent and return its summary.',
|
||||
@@ -531,6 +717,7 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=context.command_timeout_seconds,
|
||||
env=_build_subprocess_env(context),
|
||||
)
|
||||
stdout = completed.stdout or ''
|
||||
stderr = completed.stderr or ''
|
||||
@@ -554,6 +741,204 @@ def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _mcp_list_resources(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
runtime = _require_mcp_runtime(context)
|
||||
query = arguments.get('query')
|
||||
if query is not None and not isinstance(query, str):
|
||||
raise ToolExecutionError('query must be a string')
|
||||
max_resources = _coerce_int(arguments, 'max_resources', 50)
|
||||
resources = runtime.list_resources(query=query, limit=max_resources)
|
||||
if not resources:
|
||||
return '(no MCP resources)'
|
||||
lines: list[str] = []
|
||||
for resource in resources:
|
||||
details = [resource.uri, f'server={resource.server_name}']
|
||||
if resource.name:
|
||||
details.append(f'name={resource.name}')
|
||||
if resource.mime_type:
|
||||
details.append(f'mime={resource.mime_type}')
|
||||
if resource.resolved_path:
|
||||
details.append(f'path={resource.resolved_path}')
|
||||
lines.append(' ; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _mcp_read_resource(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
runtime = _require_mcp_runtime(context)
|
||||
uri = _require_string(arguments, 'uri')
|
||||
max_chars = _coerce_int(arguments, 'max_chars', context.max_output_chars)
|
||||
try:
|
||||
content = runtime.read_resource(uri, max_chars=max_chars)
|
||||
except FileNotFoundError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return content
|
||||
|
||||
|
||||
def _task_list(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
runtime = _require_task_runtime(context)
|
||||
status = arguments.get('status')
|
||||
if status is not None and not isinstance(status, str):
|
||||
raise ToolExecutionError('status must be a string')
|
||||
max_tasks = _coerce_int(arguments, 'max_tasks', 50)
|
||||
return runtime.render_tasks(status=status, limit=max_tasks)
|
||||
|
||||
|
||||
def _task_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
runtime = _require_task_runtime(context)
|
||||
return runtime.render_task(_require_string(arguments, 'task_id'))
|
||||
|
||||
|
||||
def _plan_get(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
del arguments
|
||||
runtime = _require_plan_runtime(context)
|
||||
return runtime.render_plan()
|
||||
|
||||
|
||||
def _update_plan(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_write_allowed(context)
|
||||
runtime = _require_plan_runtime(context)
|
||||
items = arguments.get('items')
|
||||
if not isinstance(items, list):
|
||||
raise ToolExecutionError('items must be an array of plan step objects')
|
||||
explanation = arguments.get('explanation')
|
||||
if explanation is not None and not isinstance(explanation, str):
|
||||
raise ToolExecutionError('explanation must be a string')
|
||||
sync_tasks = arguments.get('sync_tasks', True)
|
||||
if not isinstance(sync_tasks, bool):
|
||||
raise ToolExecutionError('sync_tasks must be a boolean')
|
||||
mutation = runtime.update_plan(
|
||||
[item for item in items if isinstance(item, dict)],
|
||||
explanation=explanation,
|
||||
task_runtime=context.task_runtime,
|
||||
sync_tasks=sync_tasks,
|
||||
)
|
||||
return (
|
||||
f'updated plan with {mutation.after_count} step(s)',
|
||||
_plan_mutation_metadata(
|
||||
action='update_plan',
|
||||
mutation=mutation,
|
||||
total_steps=mutation.after_count,
|
||||
sync_tasks=sync_tasks,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _plan_clear(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_write_allowed(context)
|
||||
runtime = _require_plan_runtime(context)
|
||||
sync_tasks = arguments.get('sync_tasks', True)
|
||||
if not isinstance(sync_tasks, bool):
|
||||
raise ToolExecutionError('sync_tasks must be a boolean')
|
||||
mutation = runtime.clear_plan(
|
||||
task_runtime=context.task_runtime if sync_tasks else None,
|
||||
)
|
||||
return (
|
||||
'cleared local plan',
|
||||
_plan_mutation_metadata(
|
||||
action='plan_clear',
|
||||
mutation=mutation,
|
||||
total_steps=0,
|
||||
sync_tasks=sync_tasks,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _task_create(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_write_allowed(context)
|
||||
runtime = _require_task_runtime(context)
|
||||
title = _require_string(arguments, 'title')
|
||||
description = arguments.get('description')
|
||||
status = arguments.get('status', 'todo')
|
||||
priority = arguments.get('priority')
|
||||
task_id = arguments.get('task_id')
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ToolExecutionError('description must be a string')
|
||||
if status is not None and not isinstance(status, str):
|
||||
raise ToolExecutionError('status must be a string')
|
||||
if priority is not None and not isinstance(priority, str):
|
||||
raise ToolExecutionError('priority must be a string')
|
||||
if task_id is not None and not isinstance(task_id, str):
|
||||
raise ToolExecutionError('task_id must be a string')
|
||||
mutation = runtime.create_task(
|
||||
title=title,
|
||||
description=description,
|
||||
status=status or 'todo',
|
||||
priority=priority,
|
||||
task_id=task_id,
|
||||
)
|
||||
task = mutation.task
|
||||
assert task is not None
|
||||
return (
|
||||
f'created task {task.task_id}: {task.title} [{task.status}]',
|
||||
_task_mutation_metadata(
|
||||
action='task_create',
|
||||
mutation=mutation,
|
||||
task_id=task.task_id,
|
||||
task_status=task.status,
|
||||
total_tasks=mutation.after_count,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _task_update(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_write_allowed(context)
|
||||
runtime = _require_task_runtime(context)
|
||||
task_id = _require_string(arguments, 'task_id')
|
||||
title = arguments.get('title')
|
||||
description = arguments.get('description')
|
||||
status = arguments.get('status')
|
||||
priority = arguments.get('priority')
|
||||
for key, value in (
|
||||
('title', title),
|
||||
('description', description),
|
||||
('status', status),
|
||||
('priority', priority),
|
||||
):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ToolExecutionError(f'{key} must be a string')
|
||||
try:
|
||||
mutation = runtime.update_task(
|
||||
task_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ToolExecutionError(f'Unknown task id: {task_id}') from exc
|
||||
task = mutation.task
|
||||
assert task is not None
|
||||
return (
|
||||
f'updated task {task.task_id}: {task.title} [{task.status}]',
|
||||
_task_mutation_metadata(
|
||||
action='task_update',
|
||||
mutation=mutation,
|
||||
task_id=task.task_id,
|
||||
task_status=task.status,
|
||||
total_tasks=mutation.after_count,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _todo_write(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_write_allowed(context)
|
||||
runtime = _require_task_runtime(context)
|
||||
items = arguments.get('items')
|
||||
if not isinstance(items, list):
|
||||
raise ToolExecutionError('items must be an array of task objects')
|
||||
mutation = runtime.replace_tasks(
|
||||
[item for item in items if isinstance(item, dict)]
|
||||
)
|
||||
return (
|
||||
f'replaced todo list with {mutation.after_count} task(s)',
|
||||
_task_mutation_metadata(
|
||||
action='todo_write',
|
||||
mutation=mutation,
|
||||
total_tasks=mutation.after_count,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _stream_bash(
|
||||
arguments: dict[str, Any],
|
||||
context: ToolExecutionContext,
|
||||
@@ -570,6 +955,7 @@ def _stream_bash(
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=_build_subprocess_env(context),
|
||||
)
|
||||
except (ToolPermissionError, ToolExecutionError, OSError, subprocess.SubprocessError) as exc:
|
||||
yield ToolStreamUpdate(
|
||||
@@ -687,6 +1073,95 @@ def _delegate_agent_placeholder(
|
||||
)
|
||||
|
||||
|
||||
def _require_mcp_runtime(context: ToolExecutionContext):
|
||||
if context.mcp_runtime is None or not context.mcp_runtime.resources:
|
||||
raise ToolExecutionError(
|
||||
'No local MCP resources are available. Add a .claw-mcp.json or .mcp.json manifest first.'
|
||||
)
|
||||
return context.mcp_runtime
|
||||
|
||||
|
||||
def _require_plan_runtime(context: ToolExecutionContext):
|
||||
if context.plan_runtime is None:
|
||||
raise ToolExecutionError('Local plan runtime is not available.')
|
||||
return context.plan_runtime
|
||||
|
||||
|
||||
def _require_task_runtime(context: ToolExecutionContext):
|
||||
if context.task_runtime is None:
|
||||
raise ToolExecutionError('Local task runtime is not available.')
|
||||
return context.task_runtime
|
||||
|
||||
|
||||
def _task_mutation_metadata(
|
||||
*,
|
||||
action: str,
|
||||
mutation,
|
||||
task_id: str | None = None,
|
||||
task_status: str | None = None,
|
||||
total_tasks: int,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
relative_path = str(Path(mutation.store_path).relative_to(Path.cwd()))
|
||||
except ValueError:
|
||||
relative_path = str(mutation.store_path)
|
||||
payload: dict[str, Any] = {
|
||||
'action': action,
|
||||
'path': relative_path,
|
||||
'before_sha256': mutation.before_sha256,
|
||||
'after_sha256': mutation.after_sha256,
|
||||
'before_preview': mutation.before_preview,
|
||||
'after_preview': mutation.after_preview,
|
||||
'before_task_count': mutation.before_count,
|
||||
'after_task_count': mutation.after_count,
|
||||
'total_tasks': total_tasks,
|
||||
}
|
||||
if task_id is not None:
|
||||
payload['task_id'] = task_id
|
||||
if task_status is not None:
|
||||
payload['task_status'] = task_status
|
||||
return payload
|
||||
|
||||
|
||||
def _plan_mutation_metadata(
|
||||
*,
|
||||
action: str,
|
||||
mutation,
|
||||
total_steps: int,
|
||||
sync_tasks: bool,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
relative_path = str(Path(mutation.store_path).relative_to(Path.cwd()))
|
||||
except ValueError:
|
||||
relative_path = str(mutation.store_path)
|
||||
payload: dict[str, Any] = {
|
||||
'action': action,
|
||||
'path': relative_path,
|
||||
'before_sha256': mutation.before_sha256,
|
||||
'after_sha256': mutation.after_sha256,
|
||||
'before_preview': mutation.before_preview,
|
||||
'after_preview': mutation.after_preview,
|
||||
'before_plan_count': mutation.before_count,
|
||||
'after_plan_count': mutation.after_count,
|
||||
'total_steps': total_steps,
|
||||
'sync_tasks': sync_tasks,
|
||||
}
|
||||
if mutation.explanation is not None:
|
||||
payload['explanation'] = mutation.explanation
|
||||
if mutation.synced_task_store_path is not None:
|
||||
try:
|
||||
payload['synced_task_store_path'] = str(
|
||||
Path(mutation.synced_task_store_path).relative_to(Path.cwd())
|
||||
)
|
||||
except ValueError:
|
||||
payload['synced_task_store_path'] = mutation.synced_task_store_path
|
||||
if mutation.synced_task_sha256 is not None:
|
||||
payload['synced_task_sha256'] = mutation.synced_task_sha256
|
||||
if mutation.synced_tasks:
|
||||
payload['synced_tasks'] = mutation.synced_tasks
|
||||
return payload
|
||||
|
||||
|
||||
def _drain_registered_streams(
|
||||
selector: selectors.BaseSelector,
|
||||
stdout_chunks: list[str],
|
||||
@@ -721,6 +1196,12 @@ def _drain_registered_streams(
|
||||
pass
|
||||
|
||||
|
||||
def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(context.extra_env)
|
||||
return env
|
||||
|
||||
|
||||
def _stream_static_text_result(
|
||||
result: ToolExecutionResult,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
DEFAULT_BACKGROUND_DIR = Path('.port_sessions') / 'background'
|
||||
_DETACHED_PROCESSES: dict[int, subprocess.Popen[Any]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackgroundSessionRecord:
|
||||
background_id: str
|
||||
pid: int
|
||||
prompt: str
|
||||
workspace_cwd: str
|
||||
model: str
|
||||
mode: str
|
||||
status: str
|
||||
log_path: str
|
||||
record_path: str
|
||||
started_at: str
|
||||
command: tuple[str, ...]
|
||||
finished_at: str | None = None
|
||||
exit_code: int | None = None
|
||||
stop_reason: str | None = None
|
||||
session_id: str | None = None
|
||||
session_path: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> 'BackgroundSessionRecord':
|
||||
return cls(
|
||||
background_id=str(payload.get('background_id') or ''),
|
||||
pid=int(payload.get('pid') or 0),
|
||||
prompt=str(payload.get('prompt') or ''),
|
||||
workspace_cwd=str(payload.get('workspace_cwd') or ''),
|
||||
model=str(payload.get('model') or ''),
|
||||
mode=str(payload.get('mode') or 'agent'),
|
||||
status=str(payload.get('status') or 'unknown'),
|
||||
log_path=str(payload.get('log_path') or ''),
|
||||
record_path=str(payload.get('record_path') or ''),
|
||||
started_at=str(payload.get('started_at') or ''),
|
||||
command=tuple(
|
||||
str(item)
|
||||
for item in payload.get('command', [])
|
||||
if isinstance(item, (str, int, float))
|
||||
),
|
||||
finished_at=(
|
||||
str(payload.get('finished_at'))
|
||||
if isinstance(payload.get('finished_at'), str) and payload.get('finished_at')
|
||||
else None
|
||||
),
|
||||
exit_code=(
|
||||
int(payload.get('exit_code'))
|
||||
if isinstance(payload.get('exit_code'), int)
|
||||
else None
|
||||
),
|
||||
stop_reason=(
|
||||
str(payload.get('stop_reason'))
|
||||
if isinstance(payload.get('stop_reason'), str) and payload.get('stop_reason')
|
||||
else None
|
||||
),
|
||||
session_id=(
|
||||
str(payload.get('session_id'))
|
||||
if isinstance(payload.get('session_id'), str) and payload.get('session_id')
|
||||
else None
|
||||
),
|
||||
session_path=(
|
||||
str(payload.get('session_path'))
|
||||
if isinstance(payload.get('session_path'), str) and payload.get('session_path')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BackgroundSessionRuntime:
|
||||
def __init__(self, root: Path | None = None) -> None:
|
||||
self.root = (root or DEFAULT_BACKGROUND_DIR).resolve()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def create_id(self) -> str:
|
||||
return f'bg_{uuid4().hex[:12]}'
|
||||
|
||||
def record_path(self, background_id: str) -> Path:
|
||||
return self.root / f'{background_id}.json'
|
||||
|
||||
def log_path(self, background_id: str) -> Path:
|
||||
return self.root / f'{background_id}.log'
|
||||
|
||||
def launch(
|
||||
self,
|
||||
command: list[str],
|
||||
*,
|
||||
prompt: str,
|
||||
workspace_cwd: Path,
|
||||
model: str,
|
||||
mode: str = 'agent',
|
||||
background_id: str | None = None,
|
||||
process_cwd: Path | None = None,
|
||||
) -> BackgroundSessionRecord:
|
||||
background_id = background_id or self.create_id()
|
||||
log_path = self.log_path(background_id)
|
||||
record_path = self.record_path(background_id)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_path.open('a', encoding='utf-8') as handle:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(process_cwd or Path.cwd()),
|
||||
start_new_session=True,
|
||||
)
|
||||
_DETACHED_PROCESSES[process.pid] = process
|
||||
record = BackgroundSessionRecord(
|
||||
background_id=background_id,
|
||||
pid=process.pid,
|
||||
prompt=prompt,
|
||||
workspace_cwd=str(workspace_cwd),
|
||||
model=model,
|
||||
mode=mode,
|
||||
status='running',
|
||||
log_path=str(log_path),
|
||||
record_path=str(record_path),
|
||||
started_at=_utc_now(),
|
||||
command=tuple(command),
|
||||
)
|
||||
self.save_record(record)
|
||||
return record
|
||||
|
||||
def save_record(self, record: BackgroundSessionRecord) -> Path:
|
||||
path = Path(record.record_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(asdict(record), ensure_ascii=True, indent=2), encoding='utf-8')
|
||||
return path
|
||||
|
||||
def load_record(self, background_id: str) -> BackgroundSessionRecord:
|
||||
data = json.loads(self.record_path(background_id).read_text(encoding='utf-8'))
|
||||
record = BackgroundSessionRecord.from_dict(data)
|
||||
return self.refresh_record(record)
|
||||
|
||||
def list_records(self) -> tuple[BackgroundSessionRecord, ...]:
|
||||
records: list[BackgroundSessionRecord] = []
|
||||
for path in sorted(self.root.glob('bg_*.json')):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
records.append(self.refresh_record(BackgroundSessionRecord.from_dict(payload)))
|
||||
return tuple(sorted(records, key=lambda item: item.started_at, reverse=True))
|
||||
|
||||
def refresh_record(self, record: BackgroundSessionRecord) -> BackgroundSessionRecord:
|
||||
if record.status != 'running':
|
||||
return record
|
||||
if _is_process_running(record.pid):
|
||||
return record
|
||||
updated = BackgroundSessionRecord(
|
||||
background_id=record.background_id,
|
||||
pid=record.pid,
|
||||
prompt=record.prompt,
|
||||
workspace_cwd=record.workspace_cwd,
|
||||
model=record.model,
|
||||
mode=record.mode,
|
||||
status='exited',
|
||||
log_path=record.log_path,
|
||||
record_path=record.record_path,
|
||||
started_at=record.started_at,
|
||||
command=record.command,
|
||||
finished_at=record.finished_at or _utc_now(),
|
||||
exit_code=record.exit_code,
|
||||
stop_reason=record.stop_reason,
|
||||
session_id=record.session_id,
|
||||
session_path=record.session_path,
|
||||
)
|
||||
self.save_record(updated)
|
||||
process = _DETACHED_PROCESSES.pop(record.pid, None)
|
||||
if process is not None and process.returncode is None:
|
||||
process.returncode = updated.exit_code
|
||||
return updated
|
||||
|
||||
def mark_finished(
|
||||
self,
|
||||
background_id: str,
|
||||
*,
|
||||
exit_code: int,
|
||||
stop_reason: str | None = None,
|
||||
session_id: str | None = None,
|
||||
session_path: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> BackgroundSessionRecord:
|
||||
record = self.load_record(background_id)
|
||||
final_status = status or ('completed' if exit_code == 0 else 'failed')
|
||||
updated = BackgroundSessionRecord(
|
||||
background_id=record.background_id,
|
||||
pid=record.pid,
|
||||
prompt=record.prompt,
|
||||
workspace_cwd=record.workspace_cwd,
|
||||
model=record.model,
|
||||
mode=record.mode,
|
||||
status=final_status,
|
||||
log_path=record.log_path,
|
||||
record_path=record.record_path,
|
||||
started_at=record.started_at,
|
||||
command=record.command,
|
||||
finished_at=_utc_now(),
|
||||
exit_code=exit_code,
|
||||
stop_reason=stop_reason,
|
||||
session_id=session_id,
|
||||
session_path=session_path,
|
||||
)
|
||||
self.save_record(updated)
|
||||
process = _DETACHED_PROCESSES.pop(record.pid, None)
|
||||
if process is not None and process.returncode is None:
|
||||
process.returncode = updated.exit_code
|
||||
return updated
|
||||
|
||||
def kill(self, background_id: str) -> BackgroundSessionRecord:
|
||||
record = self.load_record(background_id)
|
||||
if record.status != 'running':
|
||||
return record
|
||||
try:
|
||||
os.killpg(record.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
try:
|
||||
os.kill(record.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
waited_pid, _ = os.waitpid(record.pid, os.WNOHANG)
|
||||
if waited_pid == record.pid:
|
||||
break
|
||||
except ChildProcessError:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
if not _is_process_running(record.pid):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
updated = BackgroundSessionRecord(
|
||||
background_id=record.background_id,
|
||||
pid=record.pid,
|
||||
prompt=record.prompt,
|
||||
workspace_cwd=record.workspace_cwd,
|
||||
model=record.model,
|
||||
mode=record.mode,
|
||||
status='killed',
|
||||
log_path=record.log_path,
|
||||
record_path=record.record_path,
|
||||
started_at=record.started_at,
|
||||
command=record.command,
|
||||
finished_at=_utc_now(),
|
||||
exit_code=-signal.SIGTERM,
|
||||
stop_reason='killed',
|
||||
session_id=record.session_id,
|
||||
session_path=record.session_path,
|
||||
)
|
||||
self.save_record(updated)
|
||||
process = _DETACHED_PROCESSES.pop(record.pid, None)
|
||||
if process is not None and process.returncode is None:
|
||||
process.returncode = updated.exit_code
|
||||
return updated
|
||||
|
||||
def read_logs(self, background_id: str, *, tail: int | None = None) -> str:
|
||||
record = self.load_record(background_id)
|
||||
path = Path(record.log_path)
|
||||
if not path.exists():
|
||||
return ''
|
||||
text = path.read_text(encoding='utf-8', errors='replace')
|
||||
if tail is None or tail <= 0:
|
||||
return text
|
||||
lines = text.splitlines()
|
||||
return '\n'.join(lines[-tail:])
|
||||
|
||||
def render_ps(self) -> str:
|
||||
records = self.list_records()
|
||||
lines = ['# Background Sessions', '']
|
||||
if not records:
|
||||
lines.append('No local background sessions are currently recorded.')
|
||||
return '\n'.join(lines)
|
||||
for record in records:
|
||||
parts = [
|
||||
record.background_id,
|
||||
f'status={record.status}',
|
||||
f'pid={record.pid}',
|
||||
f'model={record.model}',
|
||||
f'cwd={record.workspace_cwd}',
|
||||
]
|
||||
if record.exit_code is not None:
|
||||
parts.append(f'exit_code={record.exit_code}')
|
||||
lines.append('- ' + '; '.join(parts))
|
||||
lines.append(f' prompt: {_snapshot_text(record.prompt)}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_logs(self, background_id: str, *, tail: int | None = None) -> str:
|
||||
record = self.load_record(background_id)
|
||||
log_text = self.read_logs(background_id, tail=tail)
|
||||
lines = [
|
||||
'# Background Logs',
|
||||
'',
|
||||
f'- Background session: {record.background_id}',
|
||||
f'- Status: {record.status}',
|
||||
f'- PID: {record.pid}',
|
||||
f'- Log path: {record.log_path}',
|
||||
'',
|
||||
log_text.rstrip() or '(empty log)',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_attach(self, background_id: str, *, tail: int | None = None) -> str:
|
||||
record = self.load_record(background_id)
|
||||
lines = [
|
||||
'# Background Attach',
|
||||
'',
|
||||
f'- Background session: {record.background_id}',
|
||||
f'- Status: {record.status}',
|
||||
f'- Workspace cwd: {record.workspace_cwd}',
|
||||
]
|
||||
if record.session_id:
|
||||
lines.append(f'- Agent session id: {record.session_id}')
|
||||
if record.session_path:
|
||||
lines.append(f'- Agent session path: {record.session_path}')
|
||||
lines.extend(['', self.read_logs(background_id, tail=tail).rstrip() or '(empty log)'])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def build_background_worker_command(
|
||||
*,
|
||||
background_id: str,
|
||||
prompt: str,
|
||||
forwarded_args: list[str],
|
||||
) -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
'-m',
|
||||
'src.main',
|
||||
'agent-bg-worker',
|
||||
background_id,
|
||||
prompt,
|
||||
*forwarded_args,
|
||||
]
|
||||
|
||||
|
||||
def _is_process_running(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _snapshot_text(text: str, limit: int = 140) -> str:
|
||||
normalized = ' '.join(text.split())
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return normalized[: limit - 3] + '...'
|
||||
@@ -0,0 +1,339 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookPolicyManifest:
|
||||
path: str
|
||||
trusted: bool | None = None
|
||||
managed_settings: dict[str, Any] = field(default_factory=dict)
|
||||
safe_env_names: tuple[str, ...] = ()
|
||||
deny_tools: tuple[str, ...] = ()
|
||||
deny_tool_prefixes: tuple[str, ...] = ()
|
||||
before_prompt: tuple[str, ...] = ()
|
||||
after_turn: tuple[str, ...] = ()
|
||||
before_tool: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
after_tool: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
budget_overrides: dict[str, int | float] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookPolicyRuntime:
|
||||
manifests: tuple[HookPolicyManifest, ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
cls,
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'HookPolicyRuntime':
|
||||
manifests: list[HookPolicyManifest] = []
|
||||
for path in _discover_policy_paths(cwd, additional_working_directories):
|
||||
manifest = _load_policy_manifest(path)
|
||||
if manifest is not None:
|
||||
manifests.append(manifest)
|
||||
return cls(manifests=tuple(manifests))
|
||||
|
||||
def is_trusted(self) -> bool:
|
||||
explicit = [
|
||||
manifest.trusted
|
||||
for manifest in self.manifests
|
||||
if manifest.trusted is not None
|
||||
]
|
||||
if not explicit:
|
||||
return True
|
||||
return explicit[-1]
|
||||
|
||||
def managed_settings(self) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {}
|
||||
for manifest in self.manifests:
|
||||
merged.update(manifest.managed_settings)
|
||||
return merged
|
||||
|
||||
def safe_env(self) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for manifest in self.manifests:
|
||||
for name in manifest.safe_env_names:
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
def budget_overrides(self) -> dict[str, int | float]:
|
||||
merged: dict[str, int | float] = {}
|
||||
for manifest in self.manifests:
|
||||
merged.update(manifest.budget_overrides)
|
||||
return merged
|
||||
|
||||
def before_prompt_messages(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
message
|
||||
for manifest in self.manifests
|
||||
for message in manifest.before_prompt
|
||||
)
|
||||
|
||||
def after_turn_messages(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
message
|
||||
for manifest in self.manifests
|
||||
for message in manifest.after_turn
|
||||
)
|
||||
|
||||
def before_tool_messages(self, tool_name: str) -> tuple[str, ...]:
|
||||
return self._tool_messages(tool_name, attr='before_tool')
|
||||
|
||||
def after_tool_messages(self, tool_name: str) -> tuple[str, ...]:
|
||||
return self._tool_messages(tool_name, attr='after_tool')
|
||||
|
||||
def denied_tool_message(self, tool_name: str) -> str | None:
|
||||
lowered = tool_name.lower()
|
||||
for manifest in self.manifests:
|
||||
if lowered in manifest.deny_tools:
|
||||
return (
|
||||
f'Workspace hook policy blocked tool {tool_name}. '
|
||||
f'Policy file: {manifest.path}'
|
||||
)
|
||||
if any(lowered.startswith(prefix) for prefix in manifest.deny_tool_prefixes):
|
||||
return (
|
||||
f'Workspace hook policy blocked tool prefix match for {tool_name}. '
|
||||
f'Policy file: {manifest.path}'
|
||||
)
|
||||
return None
|
||||
|
||||
def render_summary(self) -> str:
|
||||
if not self.manifests:
|
||||
return 'No local hook or policy manifests discovered.'
|
||||
lines = [f'Local hook/policy manifests: {len(self.manifests)}']
|
||||
lines.append(f'- trusted={self.is_trusted()}')
|
||||
settings = self.managed_settings()
|
||||
if settings:
|
||||
lines.append(
|
||||
'- managed_settings='
|
||||
+ ', '.join(f'{key}={value}' for key, value in sorted(settings.items()))
|
||||
)
|
||||
env_values = self.safe_env()
|
||||
if env_values:
|
||||
lines.append(
|
||||
'- safe_env='
|
||||
+ ', '.join(f'{key}={value}' for key, value in sorted(env_values.items()))
|
||||
)
|
||||
for manifest in self.manifests:
|
||||
details = [Path(manifest.path).name]
|
||||
if manifest.trusted is not None:
|
||||
details.append(f'trusted={manifest.trusted}')
|
||||
if manifest.deny_tools:
|
||||
details.append(f'deny_tools={len(manifest.deny_tools)}')
|
||||
if manifest.deny_tool_prefixes:
|
||||
details.append(f'deny_prefixes={len(manifest.deny_tool_prefixes)}')
|
||||
if manifest.before_prompt:
|
||||
details.append(f'before_prompt={len(manifest.before_prompt)}')
|
||||
if manifest.after_turn:
|
||||
details.append(f'after_turn={len(manifest.after_turn)}')
|
||||
if manifest.before_tool:
|
||||
details.append(f'before_tool={len(manifest.before_tool)}')
|
||||
if manifest.after_tool:
|
||||
details.append(f'after_tool={len(manifest.after_tool)}')
|
||||
if manifest.budget_overrides:
|
||||
details.append(f'budget_overrides={len(manifest.budget_overrides)}')
|
||||
lines.append(f"- {'; '.join(details)}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _tool_messages(self, tool_name: str, *, attr: str) -> tuple[str, ...]:
|
||||
lowered = tool_name.lower()
|
||||
messages: list[str] = []
|
||||
for manifest in self.manifests:
|
||||
mapping = getattr(manifest, attr)
|
||||
if not isinstance(mapping, dict):
|
||||
continue
|
||||
wildcard = mapping.get('*', ())
|
||||
exact = mapping.get(lowered, ())
|
||||
messages.extend(message for message in wildcard if message)
|
||||
messages.extend(message for message in exact if message)
|
||||
return tuple(messages)
|
||||
|
||||
|
||||
def _discover_policy_paths(
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...],
|
||||
) -> tuple[Path, ...]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
|
||||
def remember(path: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen or not resolved.exists() or not resolved.is_file():
|
||||
return
|
||||
seen.add(resolved)
|
||||
candidates.append(resolved)
|
||||
|
||||
roots: list[Path] = []
|
||||
current = cwd.resolve()
|
||||
while True:
|
||||
roots.append(current)
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
roots.extend(Path(path).resolve() for path in additional_working_directories)
|
||||
|
||||
for root in roots:
|
||||
remember(root / '.claw-policy.json')
|
||||
remember(root / '.codex-policy.json')
|
||||
remember(root / '.claw-hooks.json')
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _load_policy_manifest(path: Path) -> HookPolicyManifest | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
hooks = payload.get('hooks')
|
||||
if not isinstance(hooks, dict):
|
||||
hooks = {}
|
||||
return HookPolicyManifest(
|
||||
path=str(path),
|
||||
trusted=(
|
||||
payload.get('trusted')
|
||||
if isinstance(payload.get('trusted'), bool)
|
||||
else None
|
||||
),
|
||||
managed_settings=(
|
||||
dict(payload.get('managedSettings'))
|
||||
if isinstance(payload.get('managedSettings'), dict)
|
||||
else dict(payload.get('managed_settings', {}))
|
||||
if isinstance(payload.get('managed_settings'), dict)
|
||||
else {}
|
||||
),
|
||||
safe_env_names=_extract_string_tuple(
|
||||
payload.get('safeEnv')
|
||||
if payload.get('safeEnv') is not None
|
||||
else payload.get('safe_env')
|
||||
),
|
||||
deny_tools=tuple(
|
||||
item.lower()
|
||||
for item in _extract_string_tuple(
|
||||
payload.get('denyTools')
|
||||
if payload.get('denyTools') is not None
|
||||
else payload.get('deny_tools')
|
||||
)
|
||||
),
|
||||
deny_tool_prefixes=tuple(
|
||||
item.lower()
|
||||
for item in _extract_string_tuple(
|
||||
payload.get('denyToolPrefixes')
|
||||
if payload.get('denyToolPrefixes') is not None
|
||||
else payload.get('deny_tool_prefixes')
|
||||
)
|
||||
),
|
||||
before_prompt=_extract_hook_messages(
|
||||
hooks.get('beforePrompt')
|
||||
if hooks.get('beforePrompt') is not None
|
||||
else hooks.get('before_prompt')
|
||||
),
|
||||
after_turn=_extract_hook_messages(
|
||||
hooks.get('afterTurn')
|
||||
if hooks.get('afterTurn') is not None
|
||||
else hooks.get('after_turn')
|
||||
),
|
||||
before_tool=_extract_tool_hook_messages(
|
||||
hooks.get('beforeTool')
|
||||
if hooks.get('beforeTool') is not None
|
||||
else hooks.get('before_tool')
|
||||
),
|
||||
after_tool=_extract_tool_hook_messages(
|
||||
hooks.get('afterTool')
|
||||
if hooks.get('afterTool') is not None
|
||||
else hooks.get('after_tool')
|
||||
),
|
||||
budget_overrides=_extract_budget_overrides(payload.get('budget')),
|
||||
)
|
||||
|
||||
|
||||
def _extract_string_tuple(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(
|
||||
item.strip()
|
||||
for item in value
|
||||
if isinstance(item, str) and item.strip()
|
||||
)
|
||||
|
||||
|
||||
def _extract_hook_messages(value: Any) -> tuple[str, ...]:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return (value.strip(),)
|
||||
if isinstance(value, list):
|
||||
return tuple(
|
||||
item.strip()
|
||||
for item in value
|
||||
if isinstance(item, str) and item.strip()
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def _extract_tool_hook_messages(value: Any) -> dict[str, tuple[str, ...]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
extracted: dict[str, tuple[str, ...]] = {}
|
||||
for key, raw in value.items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
continue
|
||||
messages = _extract_hook_messages(raw)
|
||||
if messages:
|
||||
extracted[key.strip().lower()] = messages
|
||||
return extracted
|
||||
|
||||
|
||||
def _extract_budget_overrides(value: Any) -> dict[str, int | float]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
allowed_keys = {
|
||||
'max_total_tokens',
|
||||
'max_input_tokens',
|
||||
'max_output_tokens',
|
||||
'max_reasoning_tokens',
|
||||
'max_total_cost_usd',
|
||||
'max_tool_calls',
|
||||
'max_delegated_tasks',
|
||||
'max_model_calls',
|
||||
'max_session_turns',
|
||||
'maxTotalTokens',
|
||||
'maxInputTokens',
|
||||
'maxOutputTokens',
|
||||
'maxReasoningTokens',
|
||||
'maxTotalCostUsd',
|
||||
'maxToolCalls',
|
||||
'maxDelegatedTasks',
|
||||
'maxModelCalls',
|
||||
'maxSessionTurns',
|
||||
}
|
||||
normalized: dict[str, int | float] = {}
|
||||
key_map = {
|
||||
'maxTotalTokens': 'max_total_tokens',
|
||||
'maxInputTokens': 'max_input_tokens',
|
||||
'maxOutputTokens': 'max_output_tokens',
|
||||
'maxReasoningTokens': 'max_reasoning_tokens',
|
||||
'maxTotalCostUsd': 'max_total_cost_usd',
|
||||
'maxToolCalls': 'max_tool_calls',
|
||||
'maxDelegatedTasks': 'max_delegated_tasks',
|
||||
'maxModelCalls': 'max_model_calls',
|
||||
'maxSessionTurns': 'max_session_turns',
|
||||
}
|
||||
for key, raw in value.items():
|
||||
if key not in allowed_keys:
|
||||
continue
|
||||
normalized_key = key_map.get(key, key)
|
||||
if isinstance(raw, bool):
|
||||
continue
|
||||
if isinstance(raw, int):
|
||||
normalized[normalized_key] = raw
|
||||
elif isinstance(raw, float):
|
||||
normalized[normalized_key] = raw
|
||||
return normalized
|
||||
+164
@@ -2,11 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
from .background_runtime import BackgroundSessionRuntime, build_background_worker_command
|
||||
from .agent_runtime import LocalCodingAgent
|
||||
from .agent_types import (
|
||||
AgentPermissions,
|
||||
@@ -154,6 +156,64 @@ def _build_agent(args: argparse.Namespace) -> LocalCodingAgent:
|
||||
)
|
||||
|
||||
|
||||
def _append_agent_forwarded_args(
|
||||
command: list[str],
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
include_backend: bool,
|
||||
) -> None:
|
||||
command.extend(['--cwd', str(args.cwd)])
|
||||
command.extend(['--max-turns', str(getattr(args, 'max_turns', 12))])
|
||||
if include_backend:
|
||||
command.extend(['--model', str(args.model)])
|
||||
command.extend(['--base-url', str(args.base_url)])
|
||||
command.extend(['--api-key', str(args.api_key)])
|
||||
command.extend(['--temperature', str(args.temperature)])
|
||||
command.extend(['--timeout-seconds', str(args.timeout_seconds)])
|
||||
command.extend(['--input-cost-per-million', str(args.input_cost_per_million)])
|
||||
command.extend(['--output-cost-per-million', str(args.output_cost_per_million)])
|
||||
else:
|
||||
command.extend(['--model', str(args.model)])
|
||||
for path in getattr(args, 'add_dir', []):
|
||||
command.extend(['--add-dir', str(path)])
|
||||
for flag in (
|
||||
('--disable-claude-md', getattr(args, 'disable_claude_md', False)),
|
||||
('--allow-write', getattr(args, 'allow_write', False)),
|
||||
('--allow-shell', getattr(args, 'allow_shell', False)),
|
||||
('--unsafe', getattr(args, 'unsafe', False)),
|
||||
('--stream', getattr(args, 'stream', False)),
|
||||
('--show-transcript', getattr(args, 'show_transcript', False)),
|
||||
(
|
||||
'--response-schema-strict',
|
||||
getattr(args, 'response_schema_strict', False),
|
||||
),
|
||||
):
|
||||
if flag[1]:
|
||||
command.append(flag[0])
|
||||
for name, value in (
|
||||
('--auto-snip-threshold', getattr(args, 'auto_snip_threshold', None)),
|
||||
('--auto-compact-threshold', getattr(args, 'auto_compact_threshold', None)),
|
||||
('--compact-preserve-messages', getattr(args, 'compact_preserve_messages', None)),
|
||||
('--max-total-tokens', getattr(args, 'max_total_tokens', None)),
|
||||
('--max-input-tokens', getattr(args, 'max_input_tokens', None)),
|
||||
('--max-output-tokens', getattr(args, 'max_output_tokens', None)),
|
||||
('--max-reasoning-tokens', getattr(args, 'max_reasoning_tokens', None)),
|
||||
('--max-budget-usd', getattr(args, 'max_budget_usd', None)),
|
||||
('--max-tool-calls', getattr(args, 'max_tool_calls', None)),
|
||||
('--max-delegated-tasks', getattr(args, 'max_delegated_tasks', None)),
|
||||
('--max-model-calls', getattr(args, 'max_model_calls', None)),
|
||||
('--max-session-turns', getattr(args, 'max_session_turns', None)),
|
||||
('--response-schema-file', getattr(args, 'response_schema_file', None)),
|
||||
('--response-schema-name', getattr(args, 'response_schema_name', None)),
|
||||
('--scratchpad-root', getattr(args, 'scratchpad_root', None)),
|
||||
('--system-prompt', getattr(args, 'system_prompt', None)),
|
||||
('--append-system-prompt', getattr(args, 'append_system_prompt', None)),
|
||||
('--override-system-prompt', getattr(args, 'override_system_prompt', None)),
|
||||
):
|
||||
if value is not None:
|
||||
command.extend([name, str(value)])
|
||||
|
||||
|
||||
def _add_agent_resume_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument('session_id')
|
||||
parser.add_argument('prompt')
|
||||
@@ -487,6 +547,34 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
agent_parser.add_argument('--show-transcript', action='store_true')
|
||||
_add_agent_common_args(agent_parser, include_backend=True)
|
||||
|
||||
background_parser = subparsers.add_parser('agent-bg', help='run the Python local-model agent as a local background session')
|
||||
background_parser.add_argument('prompt')
|
||||
background_parser.add_argument('--max-turns', type=int, default=12)
|
||||
background_parser.add_argument('--show-transcript', action='store_true')
|
||||
_add_agent_common_args(background_parser, include_backend=True)
|
||||
|
||||
background_worker_parser = subparsers.add_parser('agent-bg-worker', help=argparse.SUPPRESS)
|
||||
background_worker_parser.add_argument('background_id')
|
||||
background_worker_parser.add_argument('prompt')
|
||||
background_worker_parser.add_argument('--background-root', required=True)
|
||||
background_worker_parser.add_argument('--max-turns', type=int, default=12)
|
||||
background_worker_parser.add_argument('--show-transcript', action='store_true')
|
||||
_add_agent_common_args(background_worker_parser, include_backend=True)
|
||||
|
||||
ps_parser = subparsers.add_parser('agent-ps', help='list local background agent sessions')
|
||||
ps_parser.add_argument('--tail', type=int, default=None)
|
||||
|
||||
logs_parser = subparsers.add_parser('agent-logs', help='show logs for a local background agent session')
|
||||
logs_parser.add_argument('background_id')
|
||||
logs_parser.add_argument('--tail', type=int, default=None)
|
||||
|
||||
attach_parser = subparsers.add_parser('agent-attach', help='show the current output snapshot for a local background agent session')
|
||||
attach_parser.add_argument('background_id')
|
||||
attach_parser.add_argument('--tail', type=int, default=None)
|
||||
|
||||
kill_parser = subparsers.add_parser('agent-kill', help='stop a local background agent session')
|
||||
kill_parser.add_argument('background_id')
|
||||
|
||||
chat_parser = subparsers.add_parser('agent-chat', help='run an interactive Python local-model chat loop')
|
||||
chat_parser.add_argument('prompt', nargs='?')
|
||||
chat_parser.add_argument('--resume-session-id')
|
||||
@@ -640,6 +728,82 @@ def main(argv: list[str] | None = None) -> int:
|
||||
result = agent.run(args.prompt)
|
||||
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||
return 0
|
||||
if args.command == 'agent-bg':
|
||||
background_runtime = BackgroundSessionRuntime()
|
||||
background_id = background_runtime.create_id()
|
||||
forwarded_args: list[str] = []
|
||||
_append_agent_forwarded_args(forwarded_args, args, include_backend=True)
|
||||
forwarded_args.extend(['--background-root', str(background_runtime.root)])
|
||||
command = build_background_worker_command(
|
||||
background_id=background_id,
|
||||
prompt=args.prompt,
|
||||
forwarded_args=forwarded_args,
|
||||
)
|
||||
record = background_runtime.launch(
|
||||
command,
|
||||
prompt=args.prompt,
|
||||
workspace_cwd=Path(args.cwd).resolve(),
|
||||
model=args.model,
|
||||
background_id=background_id,
|
||||
process_cwd=Path(__file__).resolve().parent.parent,
|
||||
)
|
||||
print('# Background Session')
|
||||
print(f'background_id={record.background_id}')
|
||||
print(f'pid={record.pid}')
|
||||
print(f'log_path={record.log_path}')
|
||||
print(f'record_path={record.record_path}')
|
||||
return 0
|
||||
if args.command == 'agent-bg-worker':
|
||||
background_runtime = BackgroundSessionRuntime(Path(args.background_root))
|
||||
exit_code = 1
|
||||
stop_reason = 'worker_failed'
|
||||
session_id = None
|
||||
session_path = None
|
||||
try:
|
||||
agent = _build_agent(args)
|
||||
result = agent.run(args.prompt)
|
||||
_print_agent_result(result, show_transcript=args.show_transcript)
|
||||
exit_code = 0
|
||||
stop_reason = result.stop_reason or 'completed'
|
||||
session_id = result.session_id
|
||||
session_path = result.session_path
|
||||
return 0
|
||||
finally:
|
||||
background_runtime.mark_finished(
|
||||
args.background_id,
|
||||
exit_code=exit_code,
|
||||
stop_reason=stop_reason,
|
||||
session_id=session_id,
|
||||
session_path=session_path,
|
||||
)
|
||||
if args.command == 'agent-ps':
|
||||
print(BackgroundSessionRuntime().render_ps())
|
||||
return 0
|
||||
if args.command == 'agent-logs':
|
||||
print(
|
||||
BackgroundSessionRuntime().render_logs(
|
||||
args.background_id,
|
||||
tail=args.tail,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.command == 'agent-attach':
|
||||
print(
|
||||
BackgroundSessionRuntime().render_attach(
|
||||
args.background_id,
|
||||
tail=args.tail,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.command == 'agent-kill':
|
||||
record = BackgroundSessionRuntime().kill(args.background_id)
|
||||
print('# Background Session')
|
||||
print(f'background_id={record.background_id}')
|
||||
print(f'status={record.status}')
|
||||
print(f'pid={record.pid}')
|
||||
if record.exit_code is not None:
|
||||
print(f'exit_code={record.exit_code}')
|
||||
return 0
|
||||
if args.command == 'agent-chat':
|
||||
agent = _build_agent(args)
|
||||
return _run_agent_chat_loop(
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MCPResource:
|
||||
uri: str
|
||||
server_name: str
|
||||
source_manifest: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
mime_type: str | None = None
|
||||
resolved_path: str | None = None
|
||||
inline_text: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPRuntime:
|
||||
resources: tuple[MCPResource, ...] = field(default_factory=tuple)
|
||||
|
||||
@classmethod
|
||||
def from_workspace(
|
||||
cls,
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...] = (),
|
||||
) -> 'MCPRuntime':
|
||||
resources: list[MCPResource] = []
|
||||
for path in _discover_manifest_paths(cwd, additional_working_directories):
|
||||
resources.extend(_load_resources_from_manifest(path))
|
||||
return cls(resources=tuple(resources))
|
||||
|
||||
@property
|
||||
def manifests(self) -> tuple[str, ...]:
|
||||
seen: list[str] = []
|
||||
for resource in self.resources:
|
||||
if resource.source_manifest not in seen:
|
||||
seen.append(resource.source_manifest)
|
||||
return tuple(seen)
|
||||
|
||||
def list_resources(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[MCPResource, ...]:
|
||||
resources = self.resources
|
||||
if query:
|
||||
needle = query.lower()
|
||||
resources = tuple(
|
||||
resource
|
||||
for resource in resources
|
||||
if needle in resource.uri.lower()
|
||||
or needle in resource.server_name.lower()
|
||||
or needle in (resource.name or '').lower()
|
||||
or needle in (resource.description or '').lower()
|
||||
)
|
||||
if limit is not None and limit >= 0:
|
||||
resources = resources[:limit]
|
||||
return resources
|
||||
|
||||
def get_resource(self, uri: str) -> MCPResource | None:
|
||||
for resource in self.resources:
|
||||
if resource.uri == uri:
|
||||
return resource
|
||||
return None
|
||||
|
||||
def read_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
||||
resource = self.get_resource(uri)
|
||||
if resource is None:
|
||||
raise FileNotFoundError(f'Unknown MCP resource: {uri}')
|
||||
if resource.inline_text is not None:
|
||||
return _truncate(resource.inline_text, max_chars)
|
||||
if resource.resolved_path is None:
|
||||
raise FileNotFoundError(f'MCP resource has no readable content: {uri}')
|
||||
path = Path(resource.resolved_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise FileNotFoundError(f'MCP resource file not found: {path}')
|
||||
text = path.read_text(encoding='utf-8', errors='replace')
|
||||
return _truncate(text, max_chars)
|
||||
|
||||
def render_summary(self) -> str:
|
||||
if not self.resources:
|
||||
return 'No local MCP manifests or resources discovered.'
|
||||
lines = [
|
||||
f'Local MCP manifests: {len(self.manifests)}',
|
||||
f'Local MCP resources: {len(self.resources)}',
|
||||
]
|
||||
by_server: dict[str, int] = {}
|
||||
for resource in self.resources:
|
||||
by_server[resource.server_name] = by_server.get(resource.server_name, 0) + 1
|
||||
for server_name, count in sorted(by_server.items()):
|
||||
lines.append(f'- {server_name}: {count} resource(s)')
|
||||
for manifest in self.manifests[:10]:
|
||||
manifest_name = Path(manifest).name
|
||||
manifest_count = sum(
|
||||
1 for resource in self.resources if resource.source_manifest == manifest
|
||||
)
|
||||
lines.append(f'- {manifest_name}: {manifest_count} resource(s)')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_resource_index(
|
||||
self,
|
||||
*,
|
||||
query: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
resources = self.list_resources(query=query, limit=limit)
|
||||
if not resources:
|
||||
return '# MCP Resources\n\nNo matching MCP resources discovered.'
|
||||
lines = ['# MCP Resources', '']
|
||||
for resource in resources:
|
||||
details = [resource.uri]
|
||||
details.append(f'server={resource.server_name}')
|
||||
if resource.name:
|
||||
details.append(f'name={resource.name}')
|
||||
if resource.mime_type:
|
||||
details.append(f'mime={resource.mime_type}')
|
||||
if resource.resolved_path:
|
||||
details.append(f'path={resource.resolved_path}')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_resource(self, uri: str, *, max_chars: int = 12000) -> str:
|
||||
resource = self.get_resource(uri)
|
||||
if resource is None:
|
||||
return f'# MCP Resource\n\nUnknown MCP resource: {uri}'
|
||||
lines = [
|
||||
'# MCP Resource',
|
||||
'',
|
||||
f'- URI: {resource.uri}',
|
||||
f'- Server: {resource.server_name}',
|
||||
]
|
||||
if resource.name:
|
||||
lines.append(f'- Name: {resource.name}')
|
||||
if resource.mime_type:
|
||||
lines.append(f'- MIME Type: {resource.mime_type}')
|
||||
if resource.resolved_path:
|
||||
lines.append(f'- Path: {resource.resolved_path}')
|
||||
lines.extend(['', self.read_resource(uri, max_chars=max_chars)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _discover_manifest_paths(
|
||||
cwd: Path,
|
||||
additional_working_directories: tuple[str, ...],
|
||||
) -> tuple[Path, ...]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
|
||||
def remember(path: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen or not resolved.exists() or not resolved.is_file():
|
||||
return
|
||||
seen.add(resolved)
|
||||
candidates.append(resolved)
|
||||
|
||||
roots: list[Path] = []
|
||||
current = cwd.resolve()
|
||||
while True:
|
||||
roots.append(current)
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
roots.extend(Path(path).resolve() for path in additional_working_directories)
|
||||
|
||||
for root in roots:
|
||||
remember(root / '.claw-mcp.json')
|
||||
remember(root / '.mcp.json')
|
||||
remember(root / '.codex-mcp.json')
|
||||
remember(root / 'mcp.json')
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _load_resources_from_manifest(path: Path) -> list[MCPResource]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
resources: list[MCPResource] = []
|
||||
if isinstance(payload.get('resources'), list):
|
||||
resources.extend(
|
||||
_extract_resources(
|
||||
payload.get('name') if isinstance(payload.get('name'), str) else 'local',
|
||||
payload['resources'],
|
||||
manifest_path=path,
|
||||
)
|
||||
)
|
||||
servers = payload.get('servers')
|
||||
if isinstance(servers, list):
|
||||
for item in servers:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get('name')
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
raw_resources = item.get('resources')
|
||||
if not isinstance(raw_resources, list):
|
||||
continue
|
||||
resources.extend(
|
||||
_extract_resources(name.strip(), raw_resources, manifest_path=path)
|
||||
)
|
||||
return resources
|
||||
|
||||
|
||||
def _extract_resources(
|
||||
server_name: str,
|
||||
raw_resources: list[Any],
|
||||
*,
|
||||
manifest_path: Path,
|
||||
) -> list[MCPResource]:
|
||||
resources: list[MCPResource] = []
|
||||
seen_uris: set[str] = set()
|
||||
for item in raw_resources:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
uri = item.get('uri')
|
||||
if not isinstance(uri, str) or not uri.strip():
|
||||
continue
|
||||
uri = uri.strip()
|
||||
if uri in seen_uris:
|
||||
continue
|
||||
seen_uris.add(uri)
|
||||
raw_path = item.get('path')
|
||||
if raw_path is None:
|
||||
raw_path = item.get('file')
|
||||
resolved_path: str | None = None
|
||||
if isinstance(raw_path, str) and raw_path.strip():
|
||||
candidate = Path(raw_path).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = manifest_path.parent / candidate
|
||||
resolved_path = str(candidate.resolve())
|
||||
inline_text = item.get('text')
|
||||
if not isinstance(inline_text, str):
|
||||
inline_text = None
|
||||
metadata = item.get('metadata')
|
||||
resources.append(
|
||||
MCPResource(
|
||||
uri=uri,
|
||||
server_name=server_name,
|
||||
source_manifest=str(manifest_path),
|
||||
name=item.get('name') if isinstance(item.get('name'), str) else None,
|
||||
description=(
|
||||
item.get('description')
|
||||
if isinstance(item.get('description'), str)
|
||||
else None
|
||||
),
|
||||
mime_type=(
|
||||
item.get('mimeType')
|
||||
if isinstance(item.get('mimeType'), str)
|
||||
else item.get('mime_type')
|
||||
if isinstance(item.get('mime_type'), str)
|
||||
else None
|
||||
),
|
||||
resolved_path=resolved_path,
|
||||
inline_text=inline_text,
|
||||
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
||||
)
|
||||
)
|
||||
return resources
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
head = text[: limit // 2]
|
||||
tail = text[-(limit // 2) :]
|
||||
return f'{head}\n...[truncated]...\n{tail}'
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .task_runtime import TaskRuntime
|
||||
|
||||
|
||||
DEFAULT_PLAN_RUNTIME_PATH = Path('.port_sessions') / 'plan_runtime.json'
|
||||
VALID_PLAN_STATUSES = ('pending', 'in_progress', 'completed')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
step: str
|
||||
status: str = 'pending'
|
||||
task_id: str | None = None
|
||||
description: str | None = None
|
||||
priority: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'step': self.step,
|
||||
'status': self.status,
|
||||
'task_id': self.task_id,
|
||||
'description': self.description,
|
||||
'priority': self.priority,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> 'PlanStep':
|
||||
return cls(
|
||||
step=str(payload.get('step') or ''),
|
||||
status=_normalize_plan_status(payload.get('status')),
|
||||
task_id=(
|
||||
str(payload.get('task_id'))
|
||||
if isinstance(payload.get('task_id'), str) and payload.get('task_id')
|
||||
else None
|
||||
),
|
||||
description=(
|
||||
str(payload.get('description'))
|
||||
if isinstance(payload.get('description'), str)
|
||||
and payload.get('description').strip()
|
||||
else None
|
||||
),
|
||||
priority=(
|
||||
str(payload.get('priority'))
|
||||
if isinstance(payload.get('priority'), str)
|
||||
and payload.get('priority').strip()
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanMutation:
|
||||
explanation: str | None
|
||||
store_path: str
|
||||
before_sha256: str | None
|
||||
after_sha256: str
|
||||
before_preview: str | None
|
||||
after_preview: str
|
||||
before_count: int
|
||||
after_count: int
|
||||
synced_tasks: int = 0
|
||||
synced_task_store_path: str | None = None
|
||||
synced_task_sha256: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanRuntime:
|
||||
steps: tuple[PlanStep, ...] = field(default_factory=tuple)
|
||||
explanation: str | None = None
|
||||
updated_at: str | None = None
|
||||
storage_path: Path = field(default_factory=lambda: DEFAULT_PLAN_RUNTIME_PATH.resolve())
|
||||
|
||||
@classmethod
|
||||
def from_workspace(cls, cwd: Path) -> 'PlanRuntime':
|
||||
storage_path = (cwd.resolve() / DEFAULT_PLAN_RUNTIME_PATH).resolve()
|
||||
if not storage_path.exists():
|
||||
return cls(storage_path=storage_path)
|
||||
try:
|
||||
payload = json.loads(storage_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return cls(storage_path=storage_path)
|
||||
raw_steps = payload.get('steps')
|
||||
if not isinstance(raw_steps, list):
|
||||
return cls(storage_path=storage_path)
|
||||
steps: list[PlanStep] = []
|
||||
for item in raw_steps:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
step = PlanStep.from_dict(item)
|
||||
if step.step:
|
||||
steps.append(step)
|
||||
explanation = payload.get('explanation')
|
||||
updated_at = payload.get('updated_at')
|
||||
return cls(
|
||||
steps=tuple(steps),
|
||||
explanation=(
|
||||
explanation.strip()
|
||||
if isinstance(explanation, str) and explanation.strip()
|
||||
else None
|
||||
),
|
||||
updated_at=str(updated_at) if isinstance(updated_at, str) and updated_at else None,
|
||||
storage_path=storage_path,
|
||||
)
|
||||
|
||||
def update_plan(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
explanation: str | None = None,
|
||||
task_runtime: TaskRuntime | None = None,
|
||||
sync_tasks: bool = True,
|
||||
) -> PlanMutation:
|
||||
normalized_steps: list[PlanStep] = []
|
||||
for index, item in enumerate(items, start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
step_text = item.get('step')
|
||||
if not isinstance(step_text, str) or not step_text.strip():
|
||||
continue
|
||||
task_id = item.get('task_id')
|
||||
normalized_steps.append(
|
||||
PlanStep(
|
||||
step=step_text.strip(),
|
||||
status=_normalize_plan_status(item.get('status')),
|
||||
task_id=(
|
||||
task_id.strip()
|
||||
if isinstance(task_id, str) and task_id.strip()
|
||||
else f'plan_{index}'
|
||||
),
|
||||
description=(
|
||||
item.get('description').strip()
|
||||
if isinstance(item.get('description'), str)
|
||||
and item.get('description').strip()
|
||||
else None
|
||||
),
|
||||
priority=(
|
||||
item.get('priority').strip()
|
||||
if isinstance(item.get('priority'), str)
|
||||
and item.get('priority').strip()
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
mutation = self._persist(
|
||||
tuple(normalized_steps),
|
||||
explanation=(
|
||||
explanation.strip()
|
||||
if isinstance(explanation, str) and explanation.strip()
|
||||
else None
|
||||
),
|
||||
)
|
||||
if sync_tasks and task_runtime is not None:
|
||||
task_items = [
|
||||
{
|
||||
'task_id': step.task_id or f'plan_{index}',
|
||||
'title': step.step,
|
||||
'description': step.description,
|
||||
'status': _plan_status_to_task_status(step.status),
|
||||
'priority': step.priority,
|
||||
}
|
||||
for index, step in enumerate(self.steps, start=1)
|
||||
]
|
||||
task_mutation = task_runtime.replace_tasks(task_items)
|
||||
return PlanMutation(
|
||||
explanation=mutation.explanation,
|
||||
store_path=mutation.store_path,
|
||||
before_sha256=mutation.before_sha256,
|
||||
after_sha256=mutation.after_sha256,
|
||||
before_preview=mutation.before_preview,
|
||||
after_preview=mutation.after_preview,
|
||||
before_count=mutation.before_count,
|
||||
after_count=mutation.after_count,
|
||||
synced_tasks=task_mutation.after_count,
|
||||
synced_task_store_path=task_mutation.store_path,
|
||||
synced_task_sha256=task_mutation.after_sha256,
|
||||
)
|
||||
return mutation
|
||||
|
||||
def clear_plan(self, *, task_runtime: TaskRuntime | None = None) -> PlanMutation:
|
||||
mutation = self._persist((), explanation=None)
|
||||
if task_runtime is not None:
|
||||
task_mutation = task_runtime.replace_tasks([])
|
||||
return PlanMutation(
|
||||
explanation=None,
|
||||
store_path=mutation.store_path,
|
||||
before_sha256=mutation.before_sha256,
|
||||
after_sha256=mutation.after_sha256,
|
||||
before_preview=mutation.before_preview,
|
||||
after_preview=mutation.after_preview,
|
||||
before_count=mutation.before_count,
|
||||
after_count=mutation.after_count,
|
||||
synced_tasks=task_mutation.after_count,
|
||||
synced_task_store_path=task_mutation.store_path,
|
||||
synced_task_sha256=task_mutation.after_sha256,
|
||||
)
|
||||
return mutation
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Local plan runtime file: {self.storage_path}',
|
||||
f'Total plan steps: {len(self.steps)}',
|
||||
]
|
||||
if self.explanation:
|
||||
lines.append(f'- Explanation: {self.explanation}')
|
||||
counts: dict[str, int] = {}
|
||||
for step in self.steps:
|
||||
counts[step.status] = counts.get(step.status, 0) + 1
|
||||
if counts:
|
||||
lines.append(
|
||||
'- Status counts: '
|
||||
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
|
||||
)
|
||||
if self.updated_at:
|
||||
lines.append(f'- Updated: {self.updated_at}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_plan(self) -> str:
|
||||
if not self.steps:
|
||||
return '# Plan\n\nNo stored plan is currently available.'
|
||||
lines = ['# Plan', '']
|
||||
if self.explanation:
|
||||
lines.extend(['## Explanation', self.explanation, ''])
|
||||
lines.append('## Steps')
|
||||
for index, step in enumerate(self.steps, start=1):
|
||||
details = [f'{index}. {step.step}', f'status={step.status}']
|
||||
if step.task_id:
|
||||
details.append(f'task_id={step.task_id}')
|
||||
if step.priority:
|
||||
details.append(f'priority={step.priority}')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
if step.description:
|
||||
lines.append(f' description: {step.description}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _persist(
|
||||
self,
|
||||
steps: tuple[PlanStep, ...],
|
||||
*,
|
||||
explanation: str | None,
|
||||
) -> PlanMutation:
|
||||
before_count = len(self.steps)
|
||||
before_text = self._serialize_payload(self.steps, self.explanation, self.updated_at)
|
||||
before_preview = _snapshot_text(before_text)
|
||||
before_sha256 = (
|
||||
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
|
||||
if self.storage_path.exists() or self.steps or self.explanation
|
||||
else None
|
||||
)
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
payload_text = self._serialize_payload(steps, explanation, updated_at)
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.storage_path.write_text(payload_text, encoding='utf-8')
|
||||
self.steps = steps
|
||||
self.explanation = explanation
|
||||
self.updated_at = updated_at
|
||||
after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest()
|
||||
return PlanMutation(
|
||||
explanation=explanation,
|
||||
store_path=str(self.storage_path),
|
||||
before_sha256=before_sha256,
|
||||
after_sha256=after_sha256,
|
||||
before_preview=before_preview if before_text.strip() else None,
|
||||
after_preview=_snapshot_text(payload_text),
|
||||
before_count=before_count if before_text.strip() else 0,
|
||||
after_count=len(steps),
|
||||
)
|
||||
|
||||
def _serialize_payload(
|
||||
self,
|
||||
steps: tuple[PlanStep, ...],
|
||||
explanation: str | None,
|
||||
updated_at: str | None,
|
||||
) -> str:
|
||||
payload = {
|
||||
'explanation': explanation,
|
||||
'updated_at': updated_at,
|
||||
'steps': [step.to_dict() for step in steps],
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=True, indent=2)
|
||||
|
||||
|
||||
def _normalize_plan_status(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
aliases = {
|
||||
'todo': 'pending',
|
||||
'open': 'pending',
|
||||
'done': 'completed',
|
||||
'complete': 'completed',
|
||||
'in-progress': 'in_progress',
|
||||
'in progress': 'in_progress',
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
if lowered in VALID_PLAN_STATUSES:
|
||||
return lowered
|
||||
return 'pending'
|
||||
|
||||
|
||||
def _plan_status_to_task_status(status: str) -> str:
|
||||
if status == 'completed':
|
||||
return 'done'
|
||||
if status == 'in_progress':
|
||||
return 'in_progress'
|
||||
return 'todo'
|
||||
|
||||
|
||||
def _snapshot_text(text: str, limit: int = 240) -> str:
|
||||
normalized = ' '.join(text.split())
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return normalized[: limit - 3] + '...'
|
||||
+78
-5
@@ -1,5 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .task import PortingTask
|
||||
|
||||
__all__ = ['PortingTask']
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
VALID_TASK_STATUSES = ('todo', 'in_progress', 'done', 'cancelled')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortingTask:
|
||||
task_id: str
|
||||
title: str
|
||||
status: str = 'todo'
|
||||
description: str | None = None
|
||||
priority: str | None = None
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
updated_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'task_id': self.task_id,
|
||||
'title': self.title,
|
||||
'status': self.status,
|
||||
'description': self.description,
|
||||
'priority': self.priority,
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> 'PortingTask':
|
||||
return cls(
|
||||
task_id=str(payload.get('task_id') or payload.get('id') or ''),
|
||||
title=str(payload.get('title') or ''),
|
||||
status=_normalize_task_status(payload.get('status')),
|
||||
description=(
|
||||
str(payload.get('description'))
|
||||
if isinstance(payload.get('description'), str)
|
||||
else None
|
||||
),
|
||||
priority=(
|
||||
str(payload.get('priority'))
|
||||
if isinstance(payload.get('priority'), str)
|
||||
else None
|
||||
),
|
||||
created_at=(
|
||||
str(payload.get('created_at'))
|
||||
if isinstance(payload.get('created_at'), str)
|
||||
else datetime.now(timezone.utc).isoformat()
|
||||
),
|
||||
updated_at=(
|
||||
str(payload.get('updated_at'))
|
||||
if isinstance(payload.get('updated_at'), str)
|
||||
else datetime.now(timezone.utc).isoformat()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_task_status(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
aliases = {
|
||||
'in-progress': 'in_progress',
|
||||
'in progress': 'in_progress',
|
||||
'complete': 'done',
|
||||
'completed': 'done',
|
||||
'open': 'todo',
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
if lowered in VALID_TASK_STATUSES:
|
||||
return lowered
|
||||
return 'todo'
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .task import PortingTask, VALID_TASK_STATUSES
|
||||
|
||||
|
||||
DEFAULT_TASK_RUNTIME_PATH = Path('.port_sessions') / 'task_runtime.json'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskMutation:
|
||||
task: PortingTask | None
|
||||
store_path: str
|
||||
before_sha256: str | None
|
||||
after_sha256: str
|
||||
before_preview: str | None
|
||||
after_preview: str
|
||||
before_count: int
|
||||
after_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRuntime:
|
||||
tasks: tuple[PortingTask, ...] = field(default_factory=tuple)
|
||||
storage_path: Path = field(default_factory=lambda: DEFAULT_TASK_RUNTIME_PATH.resolve())
|
||||
|
||||
@classmethod
|
||||
def from_workspace(cls, cwd: Path) -> 'TaskRuntime':
|
||||
storage_path = (cwd.resolve() / DEFAULT_TASK_RUNTIME_PATH).resolve()
|
||||
if not storage_path.exists():
|
||||
return cls(tasks=(), storage_path=storage_path)
|
||||
try:
|
||||
payload = json.loads(storage_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return cls(tasks=(), storage_path=storage_path)
|
||||
raw_tasks = payload.get('tasks')
|
||||
if not isinstance(raw_tasks, list):
|
||||
return cls(tasks=(), storage_path=storage_path)
|
||||
tasks: list[PortingTask] = []
|
||||
for item in raw_tasks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
task = PortingTask.from_dict(item)
|
||||
if not task.task_id or not task.title:
|
||||
continue
|
||||
tasks.append(task)
|
||||
return cls(tasks=tuple(tasks), storage_path=storage_path)
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> tuple[PortingTask, ...]:
|
||||
tasks = self.tasks
|
||||
if status:
|
||||
normalized = _normalize_status(status)
|
||||
tasks = tuple(task for task in tasks if task.status == normalized)
|
||||
if limit is not None and limit >= 0:
|
||||
tasks = tasks[:limit]
|
||||
return tasks
|
||||
|
||||
def get_task(self, task_id: str) -> PortingTask | None:
|
||||
for task in self.tasks:
|
||||
if task.task_id == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
status: str = 'todo',
|
||||
priority: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> TaskMutation:
|
||||
task = PortingTask(
|
||||
task_id=task_id or f'task_{uuid4().hex[:10]}',
|
||||
title=title.strip(),
|
||||
description=description.strip() if isinstance(description, str) and description.strip() else None,
|
||||
status=_normalize_status(status),
|
||||
priority=priority.strip() if isinstance(priority, str) and priority.strip() else None,
|
||||
)
|
||||
return self._persist((*self.tasks, task), task=task)
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
) -> TaskMutation:
|
||||
existing = self.get_task(task_id)
|
||||
if existing is None:
|
||||
raise KeyError(task_id)
|
||||
updated = replace(
|
||||
existing,
|
||||
title=title.strip() if isinstance(title, str) and title.strip() else existing.title,
|
||||
description=(
|
||||
description.strip()
|
||||
if isinstance(description, str) and description.strip()
|
||||
else None if description == ''
|
||||
else existing.description
|
||||
),
|
||||
status=_normalize_status(status) if status is not None else existing.status,
|
||||
priority=(
|
||||
priority.strip()
|
||||
if isinstance(priority, str) and priority.strip()
|
||||
else None if priority == ''
|
||||
else existing.priority
|
||||
),
|
||||
updated_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
tasks = tuple(updated if task.task_id == task_id else task for task in self.tasks)
|
||||
return self._persist(tasks, task=updated)
|
||||
|
||||
def replace_tasks(self, items: list[dict[str, Any]]) -> TaskMutation:
|
||||
tasks: list[PortingTask] = []
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for index, item in enumerate(items, start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = item.get('title')
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
continue
|
||||
task_id = item.get('task_id')
|
||||
if not isinstance(task_id, str) or not task_id.strip():
|
||||
task_id = item.get('id')
|
||||
tasks.append(
|
||||
PortingTask(
|
||||
task_id=(
|
||||
task_id.strip()
|
||||
if isinstance(task_id, str) and task_id.strip()
|
||||
else f'task_{index}_{uuid4().hex[:6]}'
|
||||
),
|
||||
title=title.strip(),
|
||||
description=(
|
||||
item.get('description').strip()
|
||||
if isinstance(item.get('description'), str)
|
||||
and item.get('description').strip()
|
||||
else None
|
||||
),
|
||||
status=_normalize_status(item.get('status')),
|
||||
priority=(
|
||||
item.get('priority').strip()
|
||||
if isinstance(item.get('priority'), str)
|
||||
and item.get('priority').strip()
|
||||
else None
|
||||
),
|
||||
created_at=(
|
||||
str(item.get('created_at'))
|
||||
if isinstance(item.get('created_at'), str)
|
||||
else now
|
||||
),
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
mutation = self._persist(tuple(tasks), task=None)
|
||||
return mutation
|
||||
|
||||
def render_summary(self) -> str:
|
||||
lines = [
|
||||
f'Local task runtime file: {self.storage_path}',
|
||||
f'Total tasks: {len(self.tasks)}',
|
||||
]
|
||||
counts: dict[str, int] = {}
|
||||
for task in self.tasks:
|
||||
counts[task.status] = counts.get(task.status, 0) + 1
|
||||
if counts:
|
||||
lines.append(
|
||||
'- Status counts: '
|
||||
+ ', '.join(f'{name}={count}' for name, count in sorted(counts.items()))
|
||||
)
|
||||
if self.tasks:
|
||||
preview = ', '.join(task.title for task in self.tasks[:4])
|
||||
if len(self.tasks) > 4:
|
||||
preview += f', ... (+{len(self.tasks) - 4} more)'
|
||||
lines.append(f'- Task preview: {preview}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_tasks(self, *, status: str | None = None, limit: int = 50) -> str:
|
||||
tasks = self.list_tasks(status=status, limit=limit)
|
||||
if not tasks:
|
||||
return '# Tasks\n\nNo tasks are currently stored.'
|
||||
lines = ['# Tasks', '']
|
||||
for task in tasks:
|
||||
details = [task.task_id, f'status={task.status}']
|
||||
if task.priority:
|
||||
details.append(f'priority={task.priority}')
|
||||
details.append(f'title={task.title}')
|
||||
lines.append('- ' + '; '.join(details))
|
||||
if task.description:
|
||||
lines.append(f' description: {task.description}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def render_task(self, task_id: str) -> str:
|
||||
task = self.get_task(task_id)
|
||||
if task is None:
|
||||
return f'# Task\n\nUnknown task id: {task_id}'
|
||||
lines = [
|
||||
'# Task',
|
||||
'',
|
||||
f'- ID: {task.task_id}',
|
||||
f'- Status: {task.status}',
|
||||
f'- Title: {task.title}',
|
||||
]
|
||||
if task.priority:
|
||||
lines.append(f'- Priority: {task.priority}')
|
||||
if task.description:
|
||||
lines.append(f'- Description: {task.description}')
|
||||
lines.append(f'- Updated: {task.updated_at}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _persist(
|
||||
self,
|
||||
tasks: tuple[PortingTask, ...],
|
||||
*,
|
||||
task: PortingTask | None,
|
||||
) -> TaskMutation:
|
||||
before_text = self._serialize_payload(self.tasks)
|
||||
before_preview = _snapshot_text(before_text)
|
||||
before_sha256 = (
|
||||
hashlib.sha256(before_text.encode('utf-8')).hexdigest()
|
||||
if self.storage_path.exists() or self.tasks
|
||||
else None
|
||||
)
|
||||
payload_text = self._serialize_payload(tasks)
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.storage_path.write_text(payload_text, encoding='utf-8')
|
||||
self.tasks = tasks
|
||||
after_sha256 = hashlib.sha256(payload_text.encode('utf-8')).hexdigest()
|
||||
return TaskMutation(
|
||||
task=task,
|
||||
store_path=str(self.storage_path),
|
||||
before_sha256=before_sha256,
|
||||
after_sha256=after_sha256,
|
||||
before_preview=before_preview if before_text.strip() else None,
|
||||
after_preview=_snapshot_text(payload_text),
|
||||
before_count=len(json.loads(before_text).get('tasks', [])) if before_text.strip() else 0,
|
||||
after_count=len(tasks),
|
||||
)
|
||||
|
||||
def _serialize_payload(self, tasks: tuple[PortingTask, ...]) -> str:
|
||||
payload = {
|
||||
'tasks': [task.to_dict() for task in tasks],
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=True, indent=2)
|
||||
|
||||
|
||||
def _snapshot_text(text: str, limit: int = 240) -> str:
|
||||
normalized = ' '.join(text.split())
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return normalized[: limit - 3] + '...'
|
||||
|
||||
|
||||
def _normalize_status(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower().replace('-', '_').replace(' ', '_')
|
||||
aliases = {
|
||||
'complete': 'done',
|
||||
'completed': 'done',
|
||||
'open': 'todo',
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
if lowered in VALID_TASK_STATUSES:
|
||||
return lowered
|
||||
return 'todo'
|
||||
Reference in New Issue
Block a user