diff --git a/CHANGELOG.md b/CHANGELOG.md index c45408c0..13a17255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Agent runtime tool visibility hardening.** `PythinkerToolset` now filters the tools advertised to the model by active execution policy, permission profile, root/subagent role, and plan-mode state while preserving execution-time guards as defense in depth. +- **Agent design upgrades.** Agent specs now carry mode/hidden/step/model-parameter metadata, built-in `ask` and `debug` primary agents are selectable with `--agent`, the new `scout` subagent handles external docs/API freshness research, and compaction summaries use a stable handoff-oriented structure. - **Prompt-injection defense: `UntrustedData` wrapper.** All external content returned by `ReadFile` and `FetchURL` is now wrapped in `` tags before being passed to the LLM, providing a clear boundary between trusted instructions and untrusted file/web content. The `UntrustedData` primitive escapes embedded closing tags to prevent breakout attacks. - **Agent boundary artifacts.** New `CodingArtifact` / `VerificationResult` and `VulnerabilityArtifact` / `AuditVerdict` frozen dataclasses in `pythinker_code.utils.artifacts` enforce a typed information barrier between coder and verifier subagents. - **Recon-first `planner` subagent.** A new read-only `planner` built-in agent type decomposes open-ended tasks into distinct parallel seed descriptions emitted as `` JSON, enabling structured fan-out before parallel workers start. diff --git a/docs/en/customization/agent-architecture.md b/docs/en/customization/agent-architecture.md index d75979e0..73a2853a 100644 --- a/docs/en/customization/agent-architecture.md +++ b/docs/en/customization/agent-architecture.md @@ -213,7 +213,7 @@ flowchart LR Result --> Context ``` -The toolset is both a registry and an execution boundary. It hides tools from the LLM when needed, validates tool names, parses JSON arguments, triggers hooks, converts exceptions to `ToolRuntimeError`, and returns async `ToolResult` tasks to `pythinker_core.step`. MCP tools are registered as local wrappers. Wire external tools are sent to the active Wire client as `ToolCallRequest` messages and wait for a client-provided result. +The toolset is both a registry and an execution boundary. It hides tools from the LLM when needed, validates tool names, parses JSON arguments, triggers hooks, converts exceptions to `ToolRuntimeError`, and returns async `ToolResult` tasks to `pythinker_core.step`. The advertised tool list is filtered by the active execution profile, subagent/root role, plan-mode state, and hard permission profile before each model call; tool-specific execution guards still run even if a hidden tool is somehow called. MCP tools are registered as local wrappers. Wire external tools are sent to the active Wire client as `ToolCallRequest` messages and wait for a client-provided result. ## Subagent graph @@ -312,6 +312,17 @@ Hooks are integrated at both turn and tool boundaries: Approvals flow through `ApprovalRuntime`. The runtime binds approval state to `RootWireHub`, so foreground turns, subagents, and background agents can publish approval requests back to the root UI. `PythinkerSoul.run` creates an `ApprovalSource` for each foreground turn and cancels unresolved approvals from that source when the turn exits. +## Behavioral invariants + +| Invariant | Why it matters | +|-----------|----------------| +| Tool visibility is advisory, execution guards are authoritative | The model should not see tools that the current role/profile will reject, but every tool call still passes through hard guards and approvals. | +| Permission profiles are snapshotted per step | A tool in the same assistant response cannot relax plan/read-only rules by changing session mode before another tool executes. | +| Root orchestration stays root-only | Subagents cannot launch other subagents; child work must be visible to and coordinated by the root session. | +| Subagents have isolated context | Parent agents receive summaries and status, not wholesale child histories, keeping delegation boundaries explicit. | +| Compaction rewrites context through the normal context API | The system prompt, checkpoints, active skills, background task hints, hook context, and token estimate must be restored consistently. | +| Wire is the observation boundary | UI, ACP, web, visualization, and subagent bridges should consume typed Wire events rather than reaching into soul internals. | + ## Stop conditions A normal turn stops when the latest assistant message contains no tool calls. Other stop paths are: diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index f0aeb92f..01bab231 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -4,9 +4,11 @@ An agent defines the AI's behavior, including system prompts, available tools, a ## Built-in agents -Pythinker Code provides two built-in agents. You can select one at startup with the `--agent` flag: +Pythinker Code provides built-in primary agents. You can select one at startup with the `--agent` flag: ```sh +pythinker --agent ask +pythinker --agent debug pythinker --agent okabe ``` @@ -16,6 +18,14 @@ The default agent, suitable for general use. Enabled tools: `Agent`, `AskUserQuestion`, `SetTodoList`, `Shell`, `ReadFile`, `ReadMediaFile`, `Glob`, `Grep`, `WriteFile`, `StrReplaceFile`, `SearchWeb`, `FetchURL`, `EnterPlanMode`, `ExitPlanMode`, `TaskList`, `TaskOutput`, `TaskStop` +### `ask` + +Read-only primary mode for answering questions, explaining code, and recommending approaches without modifying the workspace. + +### `debug` + +Primary mode for systematic failure diagnosis. It reproduces or inspects failure evidence first, narrows root-cause hypotheses, then applies a minimal fix only when implementation is clearly requested. + ### `okabe` An experimental agent for testing new prompts and tools. Adds `SendDMail` on top of `default`. @@ -94,7 +104,14 @@ agent: | `name` | Agent name | Yes (optional when inheriting) | | `system_prompt_path` | System prompt file path, relative to agent file | Yes (optional when inheriting) | | `system_prompt_args` | Custom arguments passed to system prompt, merged when inheriting | No | +| `model` | Default model alias for this agent or subagent | No | +| `mode` | Agent mode: `primary`, `subagent`, `all`, or `hidden` | No | +| `hidden` | Hide this agent from default selection / background support metadata | No | +| `steps` | Per-agent maximum steps per turn | No | +| `temperature` | Agent model temperature metadata | No | +| `top_p` | Agent model top-p metadata | No | | `tools` | Tool list, format is `module:ClassName` | Yes (optional when inheriting) | +| `allowed_tools` | Tool allowlist used instead of the inherited full tool list | No | | `exclude_tools` | Tools to exclude | No | | `subagents` | Subagent definitions | No | @@ -172,6 +189,8 @@ The default agent configuration includes focused built-in subagent types with di | `implementer` | Scoped implementation with minimal edits and quick verification | Read/search tools, `Shell`, write tools, web tools | | `explore` | Fast read-only codebase exploration: search, read, summarize | Read/search tools, `Shell`, web tools; no write tools | | `plan` | Implementation planning and architecture design | Read/search tools and web tools; no write tools | +| `planner` | Read-only recon planner that decomposes broad work into parallel seeds | Read/search tools and `Shell`; no write tools | +| `scout` | Read-only external docs, dependency-source, and API freshness researcher | Read/search tools, `Shell`, web tools; no write tools | | `review` | Read-only severity-scored code review | Read/search tools, `Shell`, web tools; no write tools | | `code-reviewer` | Diff-focused code review for the current branch | Read/search tools, `Shell`, web tools; no write tools | | `security-reviewer` | Diff-focused security review with validated findings | Read/search tools, `Shell`, web tools; no write tools | @@ -197,7 +216,7 @@ The following are all built-in tools in Pythinker Code. ### `Agent` - **Path**: `pythinker_code.tools.agent:Agent` -- **Description**: Start or resume a subagent instance for a focused task. Multiple built-in subagent types are available — for example `coder`, `implementer`, `explore`, `plan`, `review`, `code-reviewer`, `security-reviewer`, `debugger`, `verifier`, and `judge`; see the built-in subagent types table above for each one's tool policy. Each instance maintains its own context history and supports foreground or background execution. +- **Description**: Start or resume a subagent instance for a focused task. Multiple built-in subagent types are available — for example `coder`, `implementer`, `explore`, `plan`, `planner`, `scout`, `review`, `code-reviewer`, `security-reviewer`, `debugger`, `verifier`, and `judge`; see the built-in subagent types table above for each one's tool policy. Each instance maintains its own context history and supports foreground or background execution. | Parameter | Type | Description | |-----------|------|-------------| diff --git a/src/pythinker_code/agents/default/agent.yaml b/src/pythinker_code/agents/default/agent.yaml index 1fbc8101..d11923be 100644 --- a/src/pythinker_code/agents/default/agent.yaml +++ b/src/pythinker_code/agents/default/agent.yaml @@ -50,6 +50,9 @@ agent: planner: path: ./planner.yaml description: "Read-only recon planner that decomposes tasks into distinct parallel seeds." + scout: + path: ./scout.yaml + description: "Read-only external docs, dependency-source, and API freshness researcher." review: path: ./review.yaml description: "Read-only code review with severity-scored findings." diff --git a/src/pythinker_code/agents/default/ask.yaml b/src/pythinker_code/agents/default/ask.yaml new file mode 100644 index 00000000..1c21f63c --- /dev/null +++ b/src/pythinker_code/agents/default/ask.yaml @@ -0,0 +1,62 @@ +version: 1 +agent: + extend: ./agent.yaml + name: "ask" + mode: primary + system_prompt_args: + ROLE_ADDITIONAL: | + You are in Ask mode: a read-only assistant for answering questions, explaining code, + and recommending next steps without modifying files. + + Ask-mode rules: + - Do not edit files, write plans to disk, launch mutating tools, commit, stage, push, or run commands that modify the system. + - Use repository evidence before answering codebase, architecture, debugging, or configuration questions. + - Use direct reads for known files and exploration subagents or searches for broader questions. + - If the user asks for implementation, explain the likely approach and say they should switch to the default/code agent or explicitly ask you to proceed with changes. + - Keep answers concise and cite paths or commands when they are load-bearing. + + Final response contract: + - Start with the direct answer. + - Include evidence bullets only when the answer depends on repository inspection. + - End with blockers only if missing context prevents a reliable answer. + when_to_use: | + Use as a primary read-only mode for answering questions and explaining code without changing the workspace. + allowed_tools: + - "pythinker_code.tools.agent:Agent" + - "pythinker_code.tools.agent:RunAgents" + - "pythinker_code.tools.skill:ReadSkill" + - "pythinker_code.tools.ask_user:AskUserQuestion" + - "pythinker_code.tools.file:ReadFile" + - "pythinker_code.tools.file:ReadMediaFile" + - "pythinker_code.tools.file:Glob" + - "pythinker_code.tools.file:Grep" + - "pythinker_code.tools.file:SmartSearch" + - "pythinker_code.tools.web:SearchWeb" + - "pythinker_code.tools.web:FetchURL" + exclude_tools: + - "pythinker_code.tools.todo:SetTodoList" + - "pythinker_code.tools.memory:Memory" + - "pythinker_code.tools.scratchpad:Scratchpad" + - "pythinker_code.tools.background:TaskInput" + - "pythinker_code.tools.background:TaskHandoff" + - "pythinker_code.tools.background:TaskStop" + - "pythinker_code.tools.file:WriteFile" + - "pythinker_code.tools.file:StrReplaceFile" + - "pythinker_code.tools.plan:ExitPlanMode" + - "pythinker_code.tools.plan.enter:EnterPlanMode" + subagents: + explore: + path: ./explore.yaml + description: "Fast codebase exploration with prompt-enforced read-only behavior." + plan: + path: ./plan.yaml + description: "Read-only implementation planning and architecture design." + review: + path: ./review.yaml + description: "Read-only code review with severity-scored findings." + debugger: + path: ./debugger.yaml + description: "Failure/log/stack-trace root-cause analysis with reproduction evidence." + judge: + path: ./judge.yaml + description: "Independent final quality gate for answers and reports." diff --git a/src/pythinker_code/agents/default/debug.yaml b/src/pythinker_code/agents/default/debug.yaml new file mode 100644 index 00000000..219a8b15 --- /dev/null +++ b/src/pythinker_code/agents/default/debug.yaml @@ -0,0 +1,67 @@ +version: 1 +agent: + extend: ./agent.yaml + name: "debug" + mode: primary + system_prompt_args: + ROLE_ADDITIONAL: | + You are in Debug mode: a systematic root-cause diagnostician. + + Debug-mode protocol: + - Start by identifying 5-7 plausible causes, then narrow to the 1-2 most likely from evidence. + - Reproduce or inspect the failure before proposing a fix whenever a bounded command, log, test, or trace is available. + - Separate confirmed facts, likely hypotheses, and unknowns. + - Prefer diagnostic reads, failing tests, logs, recent diffs, callers/callees, and configuration evidence over speculation. + - Do not make broad refactors. If editing is clearly requested, apply the smallest fix that addresses the confirmed cause and verify it. + - If the cause is not confirmed, ask for the missing log, failing command, environment, or reproduction steps instead of guessing. + + Final response contract: + ### SUMMARY + Likely root cause, confidence, and whether a fix was applied. + ### EVIDENCE + Concrete logs, commands, files, lines, or reproduction results. + ### CHANGES + Modified paths and reasons, or `None.`. + ### RISKS + Alternate hypotheses or residual uncertainty. + ### BLOCKERS + Missing reproduction context, or `None.`. + when_to_use: | + Use as a primary mode for failing tests, runtime errors, stack traces, flaky failures, and debugging requests. + allowed_tools: + - "pythinker_code.tools.agent:Agent" + - "pythinker_code.tools.agent:RunAgents" + - "pythinker_code.tools.skill:ReadSkill" + - "pythinker_code.tools.ask_user:AskUserQuestion" + - "pythinker_code.tools.todo:SetTodoList" + - "pythinker_code.tools.shell:Shell" + - "pythinker_code.tools.file:ReadFile" + - "pythinker_code.tools.file:ReadMediaFile" + - "pythinker_code.tools.file:Glob" + - "pythinker_code.tools.file:Grep" + - "pythinker_code.tools.file:SmartSearch" + - "pythinker_code.tools.file:WriteFile" + - "pythinker_code.tools.file:StrReplaceFile" + - "pythinker_code.tools.web:SearchWeb" + - "pythinker_code.tools.web:FetchURL" + exclude_tools: + - "pythinker_code.tools.memory:Memory" + - "pythinker_code.tools.scratchpad:Scratchpad" + - "pythinker_code.tools.plan:ExitPlanMode" + - "pythinker_code.tools.plan.enter:EnterPlanMode" + subagents: + explore: + path: ./explore.yaml + description: "Fast codebase exploration with prompt-enforced read-only behavior." + debugger: + path: ./debugger.yaml + description: "Failure/log/stack-trace root-cause analysis with reproduction evidence." + implementer: + path: ./implementer.yaml + description: "Scoped implementation with minimal edits and verification." + verifier: + path: ./verifier.yaml + description: "Read-only validation runner for tests, lint, and builds." + judge: + path: ./judge.yaml + description: "Independent final quality gate for answers, reports, and code-change summaries." diff --git a/src/pythinker_code/agents/default/scout.yaml b/src/pythinker_code/agents/default/scout.yaml new file mode 100644 index 00000000..d7a2e26f --- /dev/null +++ b/src/pythinker_code/agents/default/scout.yaml @@ -0,0 +1,55 @@ +version: 1 +agent: + extend: ./agent.yaml + system_prompt_args: + ROLE_ADDITIONAL: | + You are now running as a subagent. All `user` messages are sent by the main agent. The main agent cannot see your context, only your last message. Treat the parent agent as your caller. Do not ask the end user questions; surface ambiguity in your final summary. + + You are a read-only scout for external documentation, dependency source, upstream repositories, and third-party APIs. + + Scout protocol: + - Prefer official docs, canonical repositories, package metadata, and source code over blog posts or memory. + - If the task names a library, SDK, cloud service, or framework, verify the current API shape before drawing conclusions. + - If local dependency source or vendored docs exist, inspect those before web research. + - Separate verified facts from inferred behavior and stale/unknown areas. + - Cite exact URLs, file paths, versions, and line ranges where available. + - Do not modify the user's workspace. Do not install dependencies. Do not clone into the workspace unless explicitly instructed by the parent. + + Final response contract: + ### SUMMARY + Direct answer with the strongest verified source. + ### EVIDENCE + Bullet list of docs, URLs, source paths, versions, or command outputs. + ### CHANGES + Always write `None.`. + ### RISKS + Staleness, version mismatches, missing docs, or `None observed.`. + ### BLOCKERS + Network/auth/access limitations, or `None.`. + when_to_use: | + Use this agent for external libraries, SDK docs, upstream source comparisons, API freshness checks, and dependency behavior research. + allowed_tools: + - "pythinker_code.tools.shell:Shell" + - "pythinker_code.tools.file:ReadFile" + - "pythinker_code.tools.file:ReadMediaFile" + - "pythinker_code.tools.file:Glob" + - "pythinker_code.tools.file:Grep" + - "pythinker_code.tools.file:SmartSearch" + - "pythinker_code.tools.skill:ReadSkill" + - "pythinker_code.tools.web:SearchWeb" + - "pythinker_code.tools.web:FetchURL" + exclude_tools: + - "pythinker_code.tools.agent:Agent" + - "pythinker_code.tools.agent:RunAgents" + - "pythinker_code.tools.ask_user:AskUserQuestion" + - "pythinker_code.tools.todo:SetTodoList" + - "pythinker_code.tools.memory:Memory" + - "pythinker_code.tools.scratchpad:Scratchpad" + - "pythinker_code.tools.background:TaskInput" + - "pythinker_code.tools.background:TaskHandoff" + - "pythinker_code.tools.background:TaskStop" + - "pythinker_code.tools.file:WriteFile" + - "pythinker_code.tools.file:StrReplaceFile" + - "pythinker_code.tools.plan:ExitPlanMode" + - "pythinker_code.tools.plan.enter:EnterPlanMode" + subagents: diff --git a/src/pythinker_code/agents/default/system.md b/src/pythinker_code/agents/default/system.md index 938facf3..ce112e60 100644 --- a/src/pythinker_code/agents/default/system.md +++ b/src/pythinker_code/agents/default/system.md @@ -12,6 +12,10 @@ Your product name is **Pythinker** and your developer is **Pythoughts-labs**. Th Always write natural-language output in the same language as the user's latest human request, unless the user explicitly asks for another language. This applies to direct replies, plans, review summaries, subagent final summaries, todo text, and continuation/repair responses. If you are a subagent and the parent prompt includes an explicit end-user language or quoted user request, use that; otherwise match the parent prompt's language. Do not switch to a provider/model default language (for example Chinese from Qwen). Keep code, commands, logs, identifiers, paths, and quoted text in their original language unless translation is requested. +# CLI Response Style + +Be direct and technical. Do not start replies with filler such as "Great", "Sure", "Okay", or "Certainly". Avoid unnecessary preamble and postamble; answer the requested thing, cite evidence when it matters, and stop. Do not end routine task-completion responses with open-ended offers for more work. Ask questions only when an answer is required to proceed safely or correctly. + Your identity, in order of priority: 1. **Code reviewer.** Diff-aware critique with severity-scored findings, anchored to specific files and lines. diff --git a/src/pythinker_code/agentspec.py b/src/pythinker_code/agentspec.py index f9689f27..776d43f1 100644 --- a/src/pythinker_code/agentspec.py +++ b/src/pythinker_code/agentspec.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any, NamedTuple, cast +from typing import Any, Literal, NamedTuple, cast import yaml from pydantic import BaseModel, Field @@ -12,12 +12,16 @@ DEFAULT_AGENT_SPEC_VERSION = "1" SUPPORTED_AGENT_SPEC_VERSIONS = (DEFAULT_AGENT_SPEC_VERSION,) +type AgentMode = Literal["primary", "subagent", "all", "hidden"] + def get_agents_dir() -> Path: return Path(__file__).parent / "agents" DEFAULT_AGENT_FILE = get_agents_dir() / "default" / "agent.yaml" +ASK_AGENT_FILE = get_agents_dir() / "default" / "ask.yaml" +DEBUG_AGENT_FILE = get_agents_dir() / "default" / "debug.yaml" OKABE_AGENT_FILE = get_agents_dir() / "okabe" / "agent.yaml" @@ -40,6 +44,13 @@ class AgentSpec(BaseModel): default_factory=dict, description="System prompt arguments" ) model: str | None = Field(default=None, description="Default model alias") + mode: AgentMode | None = Field( + default=None, description="Agent mode: primary, subagent, all, hidden" + ) + hidden: bool | None = Field(default=None, description="Hide this agent from default selection") + steps: int | None = Field(default=None, ge=1, description="Maximum steps per turn") + temperature: float | None = Field(default=None, ge=0, le=2, description="Model temperature") + top_p: float | None = Field(default=None, ge=0, le=1, description="Model top-p") when_to_use: str | None = Field(default=None, description="Usage guidance") tools: list[str] | None | Inherit = Field(default=inherit, description="Tools") # required allowed_tools: list[str] | None | Inherit = Field(default=inherit, description="Allowed tools") @@ -66,6 +77,11 @@ class ResolvedAgentSpec: system_prompt_path: Path system_prompt_args: dict[str, str] model: str | None + mode: AgentMode + hidden: bool + steps: int | None + temperature: float | None + top_p: float | None when_to_use: str tools: list[str] allowed_tools: list[str] | None @@ -100,6 +116,11 @@ def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec: system_prompt_path=agent_spec.system_prompt_path, system_prompt_args=agent_spec.system_prompt_args, model=agent_spec.model, + mode=agent_spec.mode or "primary", + hidden=bool(agent_spec.hidden), + steps=agent_spec.steps, + temperature=agent_spec.temperature, + top_p=agent_spec.top_p, when_to_use=agent_spec.when_to_use or "", tools=agent_spec.tools or [], allowed_tools=agent_spec.allowed_tools, @@ -149,6 +170,16 @@ def _load_agent_spec(agent_file: Path) -> AgentSpec: base_agent_spec.system_prompt_args[k] = v if agent_spec.model is not None: base_agent_spec.model = agent_spec.model + if agent_spec.mode is not None: + base_agent_spec.mode = agent_spec.mode + if agent_spec.hidden is not None: + base_agent_spec.hidden = agent_spec.hidden + if agent_spec.steps is not None: + base_agent_spec.steps = agent_spec.steps + if agent_spec.temperature is not None: + base_agent_spec.temperature = agent_spec.temperature + if agent_spec.top_p is not None: + base_agent_spec.top_p = agent_spec.top_p if agent_spec.when_to_use is not None: base_agent_spec.when_to_use = agent_spec.when_to_use if not isinstance(agent_spec.tools, Inherit): diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index d70fbede..dfdeee36 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -478,7 +478,7 @@ def pythinker( ] = False, # Customization agent: Annotated[ - Literal["default", "okabe"] | None, + Literal["default", "ask", "debug", "okabe"] | None, typer.Option( "--agent", help="Builtin agent specification to use. Default: builtin default agent.", @@ -574,7 +574,12 @@ def pythinker( from pythinker_host.path import HostPath - from pythinker_code.agentspec import DEFAULT_AGENT_FILE, OKABE_AGENT_FILE + from pythinker_code.agentspec import ( + ASK_AGENT_FILE, + DEBUG_AGENT_FILE, + DEFAULT_AGENT_FILE, + OKABE_AGENT_FILE, + ) from pythinker_code.app import PythinkerCLI, enable_logging from pythinker_code.config import Config, load_config_from_string from pythinker_code.exception import ConfigError @@ -656,6 +661,10 @@ def _emit_fatal_error(message: str) -> None: match agent: case "default": agent_file = DEFAULT_AGENT_FILE + case "ask": + agent_file = ASK_AGENT_FILE + case "debug": + agent_file = DEBUG_AGENT_FILE case "okabe": agent_file = OKABE_AGENT_FILE diff --git a/src/pythinker_code/prompts/compact.md b/src/pythinker_code/prompts/compact.md index ddf071d5..48543648 100644 --- a/src/pythinker_code/prompts/compact.md +++ b/src/pythinker_code/prompts/compact.md @@ -1,73 +1,51 @@ --- -The above is a list of messages in an agent conversation. You are now given a task to compact this conversation context according to specific priorities and rules. - -**Compression Priorities (in order):** -1. **Current Task State**: What is being worked on RIGHT NOW -2. **Errors & Solutions**: All encountered errors and their resolutions -3. **Code Evolution**: Final working versions only (remove intermediate attempts) -4. **System Context**: Project structure, dependencies, environment setup -5. **Design Decisions**: Architectural choices and their rationale -6. **TODO Items**: Unfinished tasks and known issues - -**Compression Rules:** -- MUST KEEP: Error messages, stack traces, working solutions, current task -- MERGE: Similar discussions into single summary points -- REMOVE: Redundant explanations, failed attempts (keep lessons learned), verbose comments -- CONDENSE: Long code blocks → keep signatures + key logic only - -**Special Handling:** -- For code: Keep full version if < 20 lines, otherwise keep signature + key logic -- For errors: Keep full error message + final solution -- For discussions: Extract decisions and action items only - -**Required Output Structure:** - - -[What we're working on now] - - - -- [Key setup/config points] -- ...more... - - - -- [Task]: [Brief outcome] -- ...more... - - - -- [Issue]: [Status/Next steps] -- ...more... - - - - - -[filename] - -**Summary:** -[What this code file does] - -**Key elements:** -- [Important functions/classes] -- ...more... - -**Latest version:** -[Critical code snippets in this file] - - - -[filename] -...Similar as above... - - -...more files... - - - -- [Any crucial information not covered above] -- ...more... - +The above is a list of messages in an agent conversation. Compact it into a stable handoff summary that lets the agent continue without rereading the dropped history. + +Output exactly the Markdown structure shown below. Keep section names and order unchanged. Use terse bullets, not prose paragraphs. Preserve exact file paths, commands, error strings, identifiers, user constraints, and verification results when known. Do not mention the summary process or that context was compacted. + +## Goal + +- [single-sentence summary of the user's current objective] + +## Constraints & Preferences + +- [user constraints, project rules, style preferences, approvals/trust boundaries, or "(none)"] + +## Progress + +### Done + +- [completed work and verified outcomes, or "(none)"] + +### In Progress + +- [current partial work, active branch/session state, or "(none)"] + +### Blocked + +- [blockers, missing info, unavailable tools, or "(none)"] + +## Key Decisions + +- [decision and why it was chosen, or "(none)"] + +## Next Steps + +- [ordered next actions with acceptance/verification where known, or "(none)"] + +## Critical Context + +- [important technical facts, errors and resolutions, risks, assumptions, or "(none)"] + +## Relevant Files + +- [path: why it matters and latest known state, or "(none)"] + +Rules: +- Keep every section even when empty. +- Preserve only final working code state; remove redundant attempts while keeping lessons from failures. +- For code snippets, keep full snippets only when short; otherwise keep signatures, changed symbols, and key logic. +- For errors, keep the exact error text and the final or next diagnostic action. +- If prior summaries are present, merge still-true details and remove stale details. diff --git a/src/pythinker_code/soul/agent.py b/src/pythinker_code/soul/agent.py index b9df91cb..2163cadb 100644 --- a/src/pythinker_code/soul/agent.py +++ b/src/pythinker_code/soul/agent.py @@ -404,6 +404,11 @@ class Agent: toolset: Toolset runtime: Runtime """Each agent has its own runtime, which should be derived from its main agent.""" + mode: str = "primary" + hidden: bool = False + steps: int | None = None + temperature: float | None = None + top_p: float | None = None async def load_agent( @@ -455,6 +460,7 @@ async def load_agent( when_to_use=builtin_spec.when_to_use, default_model=builtin_spec.model, tool_policy=tool_policy, + supports_background=not builtin_spec.hidden, ) ) @@ -532,6 +538,11 @@ async def load_agent( system_prompt=system_prompt, toolset=toolset, runtime=runtime, + mode=agent_spec.mode, + hidden=agent_spec.hidden, + steps=agent_spec.steps, + temperature=agent_spec.temperature, + top_p=agent_spec.top_p, ) diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 8640ade0..392d4967 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -324,6 +324,10 @@ def __init__( self._approval = agent.runtime.approval self._context = context self._loop_control = agent.runtime.config.loop_control + if agent.steps is not None: + self._loop_control = self._loop_control.model_copy( + update={"max_steps_per_turn": agent.steps} + ) self._current_step_no = 0 self._deliberation_generation = 0 self._sleep_inhibitor = SleepInhibitor(enabled=agent.runtime.config.prevent_idle_sleep) diff --git a/src/pythinker_code/soul/toolset.py b/src/pythinker_code/soul/toolset.py index 1c60543a..5f18c8d7 100644 --- a/src/pythinker_code/soul/toolset.py +++ b/src/pythinker_code/soul/toolset.py @@ -122,6 +122,17 @@ def _tool_defers_execution_started(tool: ToolType) -> bool: ) +def _is_external_side_effect_tool(tool: ToolType) -> bool: + """Return True for tool adapters whose side effects are not statically classified.""" + tool_type = type(tool) + module = getattr(tool_type, "__module__", "") + qualname = getattr(tool_type, "__qualname__", "") + return bool( + (module == "pythinker_code.plugin.tool" and qualname.endswith("PluginTool")) + or (module == "pythinker_code.soul.toolset" and qualname in {"MCPTool", "WireExternalTool"}) + ) + + def _mcp_stderr_log_path(runtime: Runtime, server_name: str) -> Path: safe_name = _MCP_LOG_NAME_RE.sub("_", server_name).strip("._-") or "server" log_dir = runtime.session.dir / "mcp" @@ -196,9 +207,55 @@ def find(self, tool_name_or_type: str | type[ToolType]) -> ToolType | None: @property def tools(self) -> list[Tool]: - return [ - tool.base for tool in self._tool_dict.values() if tool.name not in self._hidden_tools - ] + return [tool.base for tool in self._tool_dict.values() if self._is_tool_visible(tool)] + + def _is_tool_visible(self, tool: ToolType) -> bool: + """Return whether *tool* should be advertised to the model for this step. + + Tool-specific execution guards remain authoritative. This model-facing filter is a + defense-in-depth layer that prevents agents from repeatedly selecting tools that the + active runtime profile, execution policy, or session mode will reject anyway. + """ + if tool.name in self._hidden_tools: + return False + if self._runtime is None: + return True + + runtime = self._runtime + from pythinker_code.execution_profiles import resolve_execution_policy + from pythinker_code.soul.permission import active_permission_profile + + profile = active_permission_profile(runtime) + policy = resolve_execution_policy( + runtime.config.agent_execution_profile, + yolo=runtime.approval.is_yolo_flag(), + ) + + if tool.name in {"WriteFile", "StrReplaceFile"}: + if policy.write == "deny": + return False + return profile.allow_file_mutation or profile.allow_plan_file_mutation + + if tool.name == "Shell" and policy.shell == "deny": + return False + + if tool.name in {"SearchWeb", "FetchURL"} and policy.network == "deny": + return False + + if tool.name in {"Agent", "RunAgents"} and ( + runtime.role != "root" or policy.subagents == "deny" + ): + return False + + if tool.name == "EnterPlanMode" and runtime.session.state.plan_mode: + return False + if tool.name == "ExitPlanMode" and not runtime.session.state.plan_mode: + return False + + if _is_external_side_effect_tool(tool): + return profile.allow_file_mutation and profile.allow_shell_mutation + + return True def handle(self, tool_call: ToolCall) -> HandleResult: token = current_tool_call.set(tool_call) diff --git a/tests/core/test_agent_spec.py b/tests/core/test_agent_spec.py index 2f3300c2..f918fd72 100644 --- a/tests/core/test_agent_spec.py +++ b/tests/core/test_agent_spec.py @@ -22,6 +22,11 @@ def test_load_default_agent_spec(): assert spec.system_prompt_args == snapshot({"ROLE_ADDITIONAL": ""}) assert spec.when_to_use == snapshot("") assert spec.model == snapshot(None) + assert spec.mode == snapshot("primary") + assert spec.hidden == snapshot(False) + assert spec.steps == snapshot(None) + assert spec.temperature == snapshot(None) + assert spec.top_p == snapshot(None) assert spec.allowed_tools == snapshot(None) assert spec.exclude_tools == snapshot([]) assert spec.tools == snapshot( @@ -76,6 +81,10 @@ def test_load_default_agent_spec(): "planner.yaml", "Read-only recon planner that decomposes tasks into distinct parallel seeds.", ), + "scout": ( + "scout.yaml", + "Read-only external docs, dependency-source, and API freshness researcher.", + ), "review": ("review.yaml", "Read-only code review with severity-scored findings."), "security-reviewer": ( "security_reviewer.yaml", @@ -597,6 +606,46 @@ def test_load_agent_spec_extension(agent_file_extending: Path): assert spec.tools == snapshot(["pythinker_code.tools.think:Think"]) +def test_load_agent_spec_metadata_inherits_and_overrides(tmp_path: Path): + (tmp_path / "system.md").write_text("Base system prompt") + base = tmp_path / "base.yaml" + base.write_text( + """ +version: 1 +agent: + name: "Base Agent" + system_prompt_path: ./system.md + tools: ["pythinker_code.tools.think:Think"] + mode: subagent + hidden: true + steps: 7 + temperature: 0.2 + top_p: 0.8 +""".strip() + ) + child = tmp_path / "child.yaml" + child.write_text( + """ +version: 1 +agent: + extend: ./base.yaml + name: "Child Agent" + mode: all + hidden: false + steps: 3 +""".strip() + ) + + spec = load_agent_spec(child) + + assert spec.name == "Child Agent" + assert spec.mode == "all" + assert spec.hidden is False + assert spec.steps == 3 + assert spec.temperature == 0.2 + assert spec.top_p == 0.8 + + def test_load_agent_spec_default_extension(): """Test loading agent spec with default extension.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index e4d92bf9..bb18f72c 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -152,6 +152,24 @@ async def test_default_agent(runtime: Runtime): "pythinker_code.tools.file:SmartSearch", ), ), + ( + "scout", + "Read-only external docs, dependency-source, and API freshness researcher.", + "scout.yaml", + None, + "allowlist", + ( + "pythinker_code.tools.shell:Shell", + "pythinker_code.tools.file:ReadFile", + "pythinker_code.tools.file:ReadMediaFile", + "pythinker_code.tools.file:Glob", + "pythinker_code.tools.file:Grep", + "pythinker_code.tools.file:SmartSearch", + "pythinker_code.tools.skill:ReadSkill", + "pythinker_code.tools.web:SearchWeb", + "pythinker_code.tools.web:FetchURL", + ), + ), ( "review", "Read-only code review with severity-scored findings.", @@ -279,7 +297,6 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): "StrReplaceFile", "SearchWeb", "FetchURL", - "ExitPlanMode", "EnterPlanMode", ] ) @@ -300,6 +317,7 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): - `explore`: Fast codebase exploration with prompt-enforced read-only behavior. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill, SearchWeb, FetchURL, Model: inherit, Background: yes). When to use: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 tool calls. Prefer launching multiple explore agents concurrently when investigating independent questions. - `plan`: Read-only implementation planning and architecture design. (Tools: SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill, SearchWeb, FetchURL, Model: inherit, Background: yes). When to use: Use this agent when the parent agent needs a step-by-step implementation plan, key file identification, and architectural trade-off analysis before code changes are made. - `planner`: Read-only recon planner that decomposes tasks into distinct parallel seeds. (Tools: Shell, ReadFile, Glob, Grep, SmartSearch, Model: inherit, Background: yes). When to use: Use this agent before spawning N parallel workers on a large or open-ended task. It partitions the problem space so workers start from distinct vantage points. +- `scout`: Read-only external docs, dependency-source, and API freshness researcher. (Tools: Shell, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill, SearchWeb, FetchURL, Model: inherit, Background: yes). When to use: Use this agent for external libraries, SDK docs, upstream source comparisons, API freshness checks, and dependency behavior research. - `review`: Read-only code review with severity-scored findings. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, ReadSkill, SearchWeb, FetchURL, Model: inherit, Background: yes). When to use: Use this agent for read-only code review after changes are made or when the parent needs severity-scored findings before deciding what to fix. - `security-reviewer`: Diff-focused security review with validated findings. (Tools: Shell, SetTodoList, ReadFile, Grep, SearchWeb, FetchURL, Model: inherit, Background: yes). When to use: Use to run a diff-only security review on the current branch. Can run in parallel with `code-reviewer`. - `implementer`: Scoped implementation with minimal edits and verification. (Tools: Shell, SetTodoList, ReadFile, ReadMediaFile, Glob, Grep, SmartSearch, WriteFile, StrReplaceFile, ReadSkill, SearchWeb, FetchURL, Model: inherit, Background: yes). When to use: Use this agent when the required code change is already specified and should be implemented with minimal edits and a quick verification pass. diff --git a/tests/core/test_load_agent.py b/tests/core/test_load_agent.py index 87194e58..316b2918 100644 --- a/tests/core/test_load_agent.py +++ b/tests/core/test_load_agent.py @@ -258,6 +258,31 @@ async def test_load_agent_invalid_tools(agent_file_invalid_tools: Path, runtime: await load_agent(agent_file_invalid_tools, runtime, mcp_configs=[]) +async def test_load_agent_exposes_agent_metadata(runtime: Runtime): + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + (tmpdir / "system.md").write_text("Main agent prompt") + agent_yaml = tmpdir / "agent.yaml" + agent_yaml.write_text( + 'version: 1\nagent:\n name: "Main"\n' + " system_prompt_path: ./system.md\n" + ' tools: ["pythinker_code.tools.think:Think"]\n' + " mode: all\n" + " hidden: true\n" + " steps: 5\n" + " temperature: 0.3\n" + " top_p: 0.9\n" + ) + + agent = await load_agent(agent_yaml, runtime, mcp_configs=[]) + + assert agent.mode == "all" + assert agent.hidden is True + assert agent.steps == 5 + assert agent.temperature == 0.3 + assert agent.top_p == 0.9 + + async def test_load_agent_registers_builtin_subagent_types(runtime: Runtime): """Agent loading should register builtin subagent types without instantiating them.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/core/test_permission_profiles.py b/tests/core/test_permission_profiles.py index 8f81b1b8..d69b5a1c 100644 --- a/tests/core/test_permission_profiles.py +++ b/tests/core/test_permission_profiles.py @@ -257,6 +257,124 @@ async def test_unknown_subagent_type_defaults_to_read_only_profile( assert not await target.exists() +async def test_toolset_hides_rejected_tools_from_read_only_subagent( + runtime: Runtime, + environment: Environment, + config, +) -> None: + from pythinker_code.soul.toolset import PythinkerToolset + from pythinker_code.tools.file.read import ReadFile + from pythinker_code.tools.file.replace import StrReplaceFile + from pythinker_code.tools.web.fetch import FetchURL + from pythinker_code.tools.web.search import SearchWeb + + runtime.role = "subagent" + runtime.subagent_type = "explore" + toolset = PythinkerToolset(runtime) + toolset.add(ReadFile(runtime)) + toolset.add(WriteFile(runtime, Approval(yolo=True))) + toolset.add(StrReplaceFile(runtime, Approval(yolo=True))) + toolset.add(Shell(Approval(yolo=True), environment, runtime)) + toolset.add(SearchWeb(config, runtime)) + toolset.add(FetchURL(config, runtime)) + toolset.add(AgentTool(runtime)) + + tool_names = {tool.name for tool in toolset.tools} + + assert "ReadFile" in tool_names + assert "Shell" in tool_names # read-only shell commands are still possible + assert "SearchWeb" in tool_names + assert "FetchURL" in tool_names + assert "WriteFile" not in tool_names + assert "StrReplaceFile" not in tool_names + assert "Agent" not in tool_names + + +async def test_toolset_keeps_plan_file_tools_only_while_plan_mode_is_active( + runtime: Runtime, + environment: Environment, +) -> None: + from pythinker_code.soul.toolset import PythinkerToolset + from pythinker_code.tools.file.replace import StrReplaceFile + from pythinker_code.tools.plan import ExitPlanMode + from pythinker_code.tools.plan.enter import EnterPlanMode + + runtime.session.state.plan_mode = True + toolset = PythinkerToolset(runtime) + toolset.add(WriteFile(runtime, Approval(yolo=True))) + toolset.add(StrReplaceFile(runtime, Approval(yolo=True))) + toolset.add(Shell(Approval(yolo=True), environment, runtime)) + toolset.add(EnterPlanMode()) + toolset.add(ExitPlanMode()) + + tool_names = {tool.name for tool in toolset.tools} + + assert "WriteFile" in tool_names + assert "StrReplaceFile" in tool_names + assert "Shell" in tool_names + assert "ExitPlanMode" in tool_names + assert "EnterPlanMode" not in tool_names + + runtime.session.state.plan_mode = False + tool_names = {tool.name for tool in toolset.tools} + + assert "EnterPlanMode" in tool_names + assert "ExitPlanMode" not in tool_names + + +async def test_toolset_hides_policy_denied_shell_and_network_tools( + runtime: Runtime, + environment: Environment, + config, +) -> None: + from pythinker_code.soul.toolset import PythinkerToolset + from pythinker_code.tools.web.fetch import FetchURL + from pythinker_code.tools.web.search import SearchWeb + + runtime.config.agent_execution_profile = "plan_only" + toolset = PythinkerToolset(runtime) + toolset.add(Shell(Approval(yolo=True), environment, runtime)) + toolset.add(SearchWeb(config, runtime)) + toolset.add(FetchURL(config, runtime)) + toolset.add(AgentTool(runtime)) + + tool_names = {tool.name for tool in toolset.tools} + + assert "Shell" not in tool_names + assert "SearchWeb" not in tool_names + assert "FetchURL" not in tool_names + assert "Agent" in tool_names + + +async def test_toolset_hides_plugin_tool_in_read_only_profile( + runtime: Runtime, + tmp_path, +) -> None: + from pythinker_code.plugin import PluginToolSpec + from pythinker_code.plugin.tool import PluginTool + from pythinker_code.soul.toolset import PythinkerToolset + + runtime.role = "subagent" + runtime.subagent_type = "explore" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + toolset = PythinkerToolset(runtime) + toolset.add( + PluginTool( + PluginToolSpec( + name="plugin_tool", + description="test", + command=[sys.executable, "-c", "print('should not run')"], + ), + plugin_dir=plugin_dir, + inject={}, + config=runtime.config, + ) + ) + + assert {tool.name for tool in toolset.tools} == set() + + async def test_toolset_denies_plugin_tool_in_read_only_profile( runtime: Runtime, tmp_path, diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 1150c4f3..15d7ae19 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -82,11 +82,13 @@ def test_pyinstaller_datas(): "pythinker_code", ), ("src/pythinker_code/agents/default/agent.yaml", "pythinker_code/agents/default"), + ("src/pythinker_code/agents/default/ask.yaml", "pythinker_code/agents/default"), ( "src/pythinker_code/agents/default/code_reviewer.yaml", "pythinker_code/agents/default", ), ("src/pythinker_code/agents/default/coder.yaml", "pythinker_code/agents/default"), + ("src/pythinker_code/agents/default/debug.yaml", "pythinker_code/agents/default"), ("src/pythinker_code/agents/default/debugger.yaml", "pythinker_code/agents/default"), ("src/pythinker_code/agents/default/explore.yaml", "pythinker_code/agents/default"), ("src/pythinker_code/agents/default/implementer.yaml", "pythinker_code/agents/default"), @@ -94,6 +96,7 @@ def test_pyinstaller_datas(): ("src/pythinker_code/agents/default/plan.yaml", "pythinker_code/agents/default"), ("src/pythinker_code/agents/default/planner.yaml", "pythinker_code/agents/default"), ("src/pythinker_code/agents/default/review.yaml", "pythinker_code/agents/default"), + ("src/pythinker_code/agents/default/scout.yaml", "pythinker_code/agents/default"), ( "src/pythinker_code/agents/default/security_reviewer.yaml", "pythinker_code/agents/default",