diff --git a/docs/mkdocs/en/goal.md b/docs/mkdocs/en/goal.md new file mode 100644 index 00000000..c2e12a85 --- /dev/null +++ b/docs/mkdocs/en/goal.md @@ -0,0 +1,157 @@ +## Goal Tool Family (Persistent Session Goal) + +`GoalToolSet` exposes three tools — `create_goal`, `get_goal`, `update_goal` — aligned with Claude Code **Session Goal** capabilities. Unlike `TodoWriteTool` (multi-item checklist) and `TaskToolSet` (multi-task board), Goal maintains **at most one** persistent objective per session branch: while status is `active`, a response that **looks like a final answer** does **not** mean the work is done — the model must keep working or explicitly call `update_goal('complete' | 'blocked')`. + +The goal is serialized as a **single JSON blob** (`GoalRecord`) in `tool_context.state["goal[:]"]`, surviving across `Runner.run_async` calls. Beyond the three model tools, the full capability requires `setup_goal()` to mount **enforcement callbacks** (`before_model` / `after_model`) that intercept premature final responses and re-run within the **same invocation**. + +### Features + +- **Single-goal contract**: one `GoalRecord` per branch (`objective` + three states `active` / `complete` / `blocked`); `complete` / `blocked` are **irreversible** terminal states +- **Cross-turn persistence**: persisted via function-response state deltas; **do not** use the `temp:` prefix +- **Sub-agent isolation**: state key appends `:` +- **Enforced completion**: while `active`, `after_model` detects premature finals (no tool call, visible text, non-partial), suppresses them, and re-runs in the same invocation; `before_model` injects a user-role nudge +- **Fail-open budget**: after `max_retries` (default 3) interceptions, the final response is allowed so the loop cannot spin forever; counters live in invocation-scoped `agent_context.metadata` and are not persisted +- **Two creation paths**: + - **Model side**: `create_goal(objective=...)` — LLM creates after judging a multi-step task + - **Host side**: `start_goal(session_service, ...)` — application writes the goal before the first turn; the model does not call `create_goal` +- **Layered prompt guidance**: `DEFAULT_GUIDANCE` injected into system instruction via `before_model` when `inject_guidance=True`; hard rules enforced by store validation + callbacks +- **Concurrency safety**: `_GoalToolBase` wraps load → mutate → save in `goal_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` + +### Relationship to Todo / Task + +| Dimension | `TodoWriteTool` | `TaskToolSet` | Goal Tool Family | +| --- | --- | --- | --- | +| Granularity | Multi-item checklist | Multi-task board + deps | **Single** session objective | +| Update style | Full-list replace | Incremental by `taskId` | `create_goal` / `update_goal` | +| Can finish while incomplete? | Prompt guidance | Prompt guidance | **Callback enforcement** | +| State key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | +| Typical use | Step visibility, short lists | Long boards, dependencies | Whether the whole job is truly done | + +> Todo / Task handle **step decomposition**; Goal handles the **overall completion contract**. They can be combined, but avoid mounting too many planning tools at once. + +### GoalOptions Constructor Parameters + +Configure via `setup_goal(agent, GoalOptions(...))`: + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"goal"` | State key prefix; do not use `temp:` | +| `inject_guidance` | `bool` | `True` | Inject `DEFAULT_GUIDANCE` into system instruction in `before_model` | +| `guidance` | `str` | `DEFAULT_GUIDANCE` | Long guidance text (serial goal-tool calls, etc.) | +| `max_retries` | `int` | `3` | Same-invocation budget for intercepting premature finals; fail-open when exhausted | +| `nudge_template` | `str` | `DEFAULT_NUDGE` | User-role reminder after interception; supports `{attempt}` / `{max_retries}` / `{objective}` | +| `on_retry` | `Callable[[RetryEvent], None]` | `None` | Observability callback on each interception or budget exhaustion | + +Mounting only `GoalToolSet()` without enforcement gives model-facing tools but **not** "no final while active". + +### LLM Parameters for the Three Tools + +**`create_goal`** + +| Parameter | Required | Description | +|------|------|------| +| `objective` | Yes | Completion criteria — what "done" concretely means | + +Success: `{message, goal}`; if an `active` goal already exists: `{error: "INVALID_STATE: ..."}`. + +**`get_goal`** + +No parameters. With a goal: `{message, goal}`; without: `{message: "No session goal is set."}`. + +**`update_goal`** + +| Parameter | Required | Description | +|------|------|------| +| `status` | Yes | `complete` (objective met) or `blocked` (same blocker repeats; cannot proceed without user input) | + +Success: `{message, goal}`; no active goal or already terminal: `{error: "INVALID_STATE: ..."}`. + +**`GoalRecord` fields** (persisted with camelCase JSON aliases): + +| Field | Description | +|------|------| +| `id` | Server-assigned uuid | +| `objective` | Completion criteria text | +| `status` | `active` / `complete` / `blocked` | +| `createdAtUnix` / `updatedAtUnix` | Created / last-updated time (unix seconds) | +| `terminalAtUnix` | Time entered a terminal state (optional) | + +### Enforcement Workflow + +```text +Model outputs final text (no tool call, goal still active) + ↓ +after_model classifies as premature final + ↓ +Suppress final (not committed as answer), retry_count += 1 +before_model injects nudge, same invocation continues agent loop + ↓ +retry_count >= max_retries → fail-open, on_retry(reason="exhausted") +``` + +Interception condition (`_is_premature_final`): non-partial, no error, visible text in content, and **no** `function_call` / `function_response`. + +### Usage + +Recommended one-line mount of tools + callbacks: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal + +def on_retry(event: RetryEvent) -> None: + if event.reason == "blocked": + print(f"Premature final intercepted (attempt {event.attempt_number}/{event.max_retries})") + +agent = LlmAgent( + name="goal_agent", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="Use goal tools to track completion for multi-step engineering tasks.", + tools=[...], # Business tools, e.g. BashTool / WriteTool +) +setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) +``` + +Host pre-injects a goal before the first turn (model does not call `create_goal`): + +```python +from trpc_agent_sdk.tools.goal_tools import start_goal + +goal = await start_goal( + session_service, + app_name="my_app", + user_id="user_1", + session_id=session_id, + objective="Create notes/ with summary.txt and example.py in the current directory", + agent_name=agent.name, # Match LlmAgent.name for branch isolation +) +``` + +Read back the persisted goal (REST / audit / demo wrap-up): + +```python +from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal + +goal = get_goal_record(session, branch=agent.name) +print(render_goal(goal)) +# ✅ Goal [complete] +# objective: ... +# created: 1782893110 +# terminal: 1782893116 +``` + +### Goal Tool Family Best Practices + +- **Use `setup_goal`, not `GoalToolSet` alone**: only callbacks enforce "no final while active" +- **Model vs host**: slash commands, `/goal`, config-driven tasks → `start_goal()`; let the model judge multi-step work → `create_goal` +- **One goal tool per response**: `DEFAULT_GUIDANCE` requires serial semantics; do not call `create_goal` and `update_goal` in the same turn +- **Use `blocked` sparingly**: only when the same blocker repeats across attempts and user input or external state change is required; do not mark blocked because work is hard, slow, or incomplete +- **Observability**: use `on_retry` to log premature-final interceptions and budget exhaustion when tuning prompt or `max_retries` +- **Division of labor with Todo / Task**: Todo / Task show steps and dependencies; Goal constrains whether the whole job is truly finished + +### Goal Tool Family Complete Example + +| Example | Description | +| --- | --- | +| [examples/goal_tools](../../../examples/goal_tools/) | Case 1: model `create_goal`; Case 2: host `start_goal` pre-injection; demonstrates enforcement interception and `update_goal(complete)` | diff --git a/docs/mkdocs/en/index.md b/docs/mkdocs/en/index.md index 3110ecb1..99848856 100644 --- a/docs/mkdocs/en/index.md +++ b/docs/mkdocs/en/index.md @@ -16,6 +16,7 @@ Welcome to the English documentation for tRPC-Agent-Python. - [Memory](./memory.md): Store and retrieve long-term memories. - [Knowledge](./knowledge.md): Build RAG workflows with LangChain components. - [Multi-Agent](./multi_agents.md): Compose agents for complex workflows. +- [Plan Mode](./plan.md): Design-then-implement workflow with write gate and HITL approval. - [Evaluation](./evaluation.md): Evaluate agent behavior and response quality. For source code and examples, see the [GitHub repository](https://github.com/trpc-group/trpc-agent-python). diff --git a/docs/mkdocs/en/plan.md b/docs/mkdocs/en/plan.md new file mode 100644 index 00000000..699a4fd0 --- /dev/null +++ b/docs/mkdocs/en/plan.md @@ -0,0 +1,343 @@ +# Plan Mode + +Plan Mode adds a **design-then-implement** workflow to `LlmAgent`: during planning, the model may only use read-only tools and draft a plan document; write-capable tools stay blocked until a human approves the plan via Human-In-The-Loop (HITL). + +It pairs naturally with `SpawnSubAgentTool` (`EXPLORE_AGENT` / `PLAN_AGENT`) for codebase research and solution design, and with `TodoWriteTool` or `TaskToolSet` after approval to track implementation progress. + +## What It Solves + +A typical coding agent may start editing files immediately and change direction mid-flight. Plan Mode splits the workflow into two phases: + +1. **Planning** — explore with read-only tools (`Read` / `Grep` / `Glob`), spawn read-only sub-agents (`Explore` / `Plan`), write the plan via `update_plan_content`, and ask clarifying questions. Side-effect tools (`Write` / `Edit` / `Bash`, `todo_write`, `task_create`, etc.) are gated. +2. **Implementation** — after human approval, write tools unlock and the model implements against the approved plan. + +Three human-touch points — `enter_plan_mode`, `exit_plan_mode`, and `ask_user_question` — are `LongRunningFunctionTool`s. Execution pauses until the host resumes with a tool function response. See [Human in the Loop](./human_in_the_loop.md) for the general HITL mechanism. + +## Why It Matters + +One of the biggest risks for a coding agent is not writing buggy code — it is **building the wrong thing correctly**. When a user says "refactor the auth module", the agent might choose JWT while the user had OAuth2 in mind. If the agent starts implementing immediately, a dozen files may already be changed before the mismatch is discovered. + +Plan Mode addresses **intent alignment**: before any code is modified, the agent explores the codebase, drafts a plan, and obtains human approval. This is not a simple "ask before doing" flag — it is a full state machine involving: + +- Write-tool gate (permission-level behavioural constraint) +- Plan document persistence (alignment artefact) +- Workflow prompt injection +- Two HITL checkpoints (enter + exit) +- UI Plan toggle integration (`agent_mode=plan`) + +## Solution: Four-Step Closed Loop + +Plan Mode introduces a **read-only phase** in the conversation, closed by two long-running tools — `enter_plan_mode` and `exit_plan_mode`: + +| Step | Name | Behaviour | +| --- | --- | --- | +| 1 | **Enter Plan Mode** | The model decides planning is needed, or the user selects Plan Mode in the UI; calls `enter_plan_mode` (HITL: user must confirm entry) | +| 2 | **Exploration** | Permission constraint is read-only: only `Read` / `Grep` / `Glob`, read-only sub-agents (`Explore` / `Plan`), and `update_plan_content` are allowed; all writes are intercepted in `before_tool` | +| 3 | **Submit for approval** | After exploration, calls `exit_plan_mode` and submits the plan document for human review (HITL: approve or reject) | +| 4 | **Resume execution** | On approval, status becomes `approved`, the write gate lifts, and the agent implements with full tool permissions | + +```mermaid +flowchart LR + A["① enter_plan_mode
Enter Plan Mode"] --> B["② Exploration
Read-only tools"] + B --> C["update_plan_content
Draft plan"] + C --> D["③ exit_plan_mode
Submit for review"] + D --> E{Approved?} + E -->|Reject| C + E -->|Approve| F["④ approved
Write tools unlocked"] + F --> G["Implement approved plan"] +``` + +```text +User / model HITL Read-only gate HITL Full access + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +enter_plan_mode ──approve──▶ exploring/drafting ──draft──▶ exit_plan_mode ──approve──▶ approved → Write/Edit/... +``` + +## Key Design Decisions + +From an engineering perspective, Plan Mode embodies three core decisions (aligned with the Claude Code Plan Mode philosophy; mapped to tRPC-Agent-Python as follows): + +### 1. Permission mode as behavioural constraint + +Once in Plan Mode, the tool surface is restricted to read-only operations — **not** via a prompt like "please do not edit files", but by intercepting write tool calls in `before_tool` before execution (`PLAN_MODE_GATE` error). Sub-agents are constrained too: `spawn_subagent` only allows `Explore` / `Plan` archetypes so the parent cannot delegate write-capable tool surfaces. + +### 2. Plan document as alignment artefact + +The plan is not ephemeral chat text — it is a persisted **Markdown artefact** (`PlanRecord.content`) stored in session state (`plan[:branch]`) and surviving across `Runner.run_async` calls. Reviewers can edit the plan on approval (pass `content` in the `exit_plan_mode` resume payload); AG-UI exposes it via `STATE_SNAPSHOT` for a live plan panel, keeping remote sessions and local UI aligned. + +### 3. State machine, not a boolean flag + +Plan Mode is not a simple `isPlanMode` switch. It is a full transition chain (`PlanStatus`) — enter, explore, draft, approve, exit, recover — where each transition has side effects: + +| Transition | Side effect | +| --- | --- | +| `enter_plan_mode` approved | Create `PlanRecord`, enter `exploring`, enable write gate | +| `update_plan_content` | `exploring` → `drafting`, append/replace plan body | +| `exit_plan_mode` | → `pending_approval`, pause for review | +| Approve | → `approved`, disable write gate, release plan lock | +| Reject | → `drafting`, allow revision and resubmission | + +## Architecture + +``` +orchestrator (LlmAgent + setup_plan) +├── business tools (e.g. FileToolSet, SpawnSubAgentTool, TodoWriteTool) +└── PlanToolSet (mounted by setup_plan) + ├── enter_plan_mode (LongRunningFunctionTool — HITL) + ├── update_plan_content + ├── exit_plan_mode (LongRunningFunctionTool — HITL) + └── ask_user_question (LongRunningFunctionTool — HITL) + +_PlanCallbacks (before_model / before_tool) +├── inject plan / awareness prompts +├── process HITL resume payloads +├── auto-enter when session state signals UI Plan toggle +└── block write tools while plan gate is active +``` + +- The plan document is persisted in the **main agent session** at `state["plan[:]"]` (default prefix `plan`). +- Spawned sub-agents return text only; they do not mutate the parent's plan state. + +## State Machine + +| Status | Meaning | Write gate | +| --- | --- | --- | +| `pending_enter` | Waiting for human to confirm entering Plan Mode | Active | +| `exploring` | Read-only exploration | Active | +| `drafting` | Plan content being written | Active | +| `pending_approval` | Plan submitted, awaiting human review | Active | +| `approved` | Human approved; implementation may begin | **Off** | + +Typical flow: + +```text +enter_plan_mode (HITL) → exploring → update_plan_content → drafting + → exit_plan_mode (HITL) → pending_approval + → approved → implement with write tools +``` + +If the human rejects at `exit_plan_mode`, status returns to `drafting` for revision. + +## Features + +- **Session-scoped plan artifact** — `PlanRecord` serialised as JSON in session state; survives across `Runner.run_async` calls +- **Write gate** — `before_tool` blocks tools in `DEFAULT_WRITE_TOOL_NAMES` while the gate is active +- **Sub-agent restrictions** — `spawn_subagent` limited to read-only archetypes (`Explore`, `Plan`); `dynamic_subagent` must explicitly restrict `tools` to a read-only subset +- **Prompt injection** — awareness prompt when no active plan; full Plan Mode prompt while gate is active +- **HITL tools** — three long-running tools pause for human input; resume handled in `before_model` +- **UI auto-enter** — when session state `agent_mode=plan`, auto-enters Plan Mode and hides `enter_plan_mode` from the tool schema +- **Idempotent HITL resume** — only the latest user turn's function responses are applied; stale rejections in history are not replayed +- **Concurrency safety** — plan tools use `plan_store_lock` (per session + branch) for load → mutate → save + +## Comparison with Todo / Task / Goal + +| Dimension | TodoWriteTool | TaskToolSet | Goal | **Plan Mode** | +| --- | --- | --- | --- | --- | +| Purpose | Step checklist | Task board + dependencies | Session completion contract | **Design doc + approval before writes** | +| Human approval | No | No | No (enforcement only) | **Yes (enter + exit HITL)** | +| Blocks write tools | No (prompt only) | No | No | **Yes (code-enforced gate)** | +| State key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | `plan[:branch]` | +| Typical use | Track steps after plan | Long boards, dependencies | "Is the whole job done?" | **Explore → draft → approve → implement** | + +> Todo / Task track execution steps; Goal enforces completion; **Plan Mode gates side effects until a human signs off on the design.** + +## PlanOptions + +Configure via `setup_plan(agent, PlanOptions(...))`: + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `state_key_prefix` | `str` | `"plan"` | Session state key prefix; do not use `temp:` | +| `plan_prompt` | `str` | `DEFAULT_PLAN_MODE_PROMPT` | Injected while gate is active | +| `awareness_prompt` | `str` | `DEFAULT_PLAN_AWARENESS_PROMPT` | Injected when no active plan | +| `write_tool_names` | `FrozenSet[str]` | `DEFAULT_WRITE_TOOL_NAMES` | Tool names blocked during gate; extend for MCP / custom write tools | +| `inject_prompt` | `bool` | `True` | Inject plan prompt when gate active | +| `inject_awareness` | `bool` | `True` | Inject awareness prompt otherwise | +| `force_enter_plan_state_key` | `Optional[str]` | `"agent_mode"` | Session key for UI-driven auto-enter; `None` disables | +| `force_enter_plan_state_value` | `str` | `"plan"` | Value that triggers auto-enter | +| `on_approval` | `Callable` | `None` | Callback on `exit_plan_mode` approve / reject | +| `readonly_subagent_types` | `FrozenSet[str]` | `{"Explore", "Plan"}` | Allowed `spawn_subagent` archetypes during gate | +| `readonly_tool_names` | `FrozenSet[str]` | `{"Read", "Grep", "Glob", "webfetch", "websearch"}` | Allowed `dynamic_subagent` tool subset | + +> Call `setup_plan()` **once** per agent. Repeated calls append duplicate toolsets and callbacks. + +## Tools + +### `enter_plan_mode` (LongRunningFunctionTool) + +Request human confirmation before entering Plan Mode. + +| Parameter | Required | Description | +| --- | --- | --- | +| `objective` | Yes | Short description of what to plan | + +Returns `{status: "pending_enter", message, objective, plan_id, approval_id, ...}`. Resume with `{status: "approved"}` or `{status: "rejected", reviewer_note?: "..."}`. + +Omitted from the tool schema when `agent_mode=plan` is set or a plan gate is already active. + +### `update_plan_content` + +Append or replace Markdown plan text. + +| Parameter | Required | Description | +| --- | --- | --- | +| `content` | Yes | Plan Markdown | +| `mode` | No | `"append"` (default) or `"replace"` | + +Moves status `exploring` → `drafting` on first write. + +### `exit_plan_mode` (LongRunningFunctionTool) + +Submit the plan for human approval. + +| Parameter | Required | Description | +| --- | --- | --- | +| `summary` | No | Short summary for the reviewer | + +Requires non-empty plan content. Returns `{status: "pending_approval", content, preview, ...}`. Resume with: + +```json +{"status": "approved"} +``` + +or + +```json +{"status": "rejected", "reviewer_note": "Add error handling section"} +``` + +Optional `content` on approve applies an edited plan from the reviewer. + +### `ask_user_question` (LongRunningFunctionTool) + +Structured clarification during an active plan. + +| Parameter | Required | Description | +| --- | --- | --- | +| `question` | Yes | Question text | +| `options` | No | Suggested answers | + +Resume with `{status: "answered", question_id: , answer: ""}`. + +## Write Gate Rules + +While `PlanRecord.is_gate_active()` (`exploring`, `drafting`, or `pending_approval`): + +| Tool kind | Rule | +| --- | --- | +| Plan tools (`enter_plan_mode`, `update_plan_content`, `exit_plan_mode`, `ask_user_question`) | Always allowed (with state checks) | +| `spawn_subagent` | Only `Explore` and `Plan` archetypes | +| `dynamic_subagent` | Only if `tools` is an explicit subset of `readonly_tool_names` | +| Names in `write_tool_names` | Blocked with `PLAN_MODE_GATE` error | + +Default blocked names: `Write`, `Edit`, `Bash`, `todo_write`, `task_create`, `task_update`, `create_goal`, `update_goal`. + +## HITL Resume (Host Integration) + +On resume, submit a user message whose parts include a `function_response` for the paused tool. `before_model` calls `process_hitl_function_response` and replaces the raw host payload with the state machine's standardised result (message + full plan dump) so the model sees the same shape as a normal tool return. + +**Enter Plan Mode — approve:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="enter_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**Exit Plan Mode — approve:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**Ask user question — answer:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="ask_user_question", + response={"status": "answered", "question_id": 1, "answer": "Use PostgreSQL"}, + ))], +) +``` + +With [AG-UI](./agui.md), long-running tools do not emit `TOOL_CALL_RESULT` until resumed; the host sends the function response on the next run. `STATE_SNAPSHOT` events expose `plan:` for live plan panels. + +## Entry Methods + +Plan Mode can be entered via two paths, differing in who triggers entry and whether `pending_enter` HITL is required: + +| Dimension | Method 1: model calls `enter_plan_mode` | Method 2: session state signal (UI-driven) | +| --- | --- | --- | +| Trigger | Model decides planning is needed | Host / UI writes session state | +| Initial status | `pending_enter` → HITL confirm → `exploring` | Enters `exploring` directly | +| Entry confirmation | Yes | No | +| `enter_plan_mode` tool | Exposed normally (when no gate) | Hidden from schema; calls return `PLAN_MODE_GATE` | + +### Method 1: model calls `enter_plan_mode` (HITL confirmation) + +**Flow:** + +1. The model decides the task needs planning (guided by the awareness prompt) +2. Calls `enter_plan_mode(objective="...")` +3. Status becomes `pending_enter`; execution pauses; the host shows a confirmation card +4. On user approval, execution resumes into `exploring` and the write gate activates + +### Method 2: session state signal forces auto-enter (UI-driven) + +**Flow:** + +1. The host writes `agent_mode = "plan"` into session state (defaults; customise via `PlanOptions`) +2. On the next invocation, `before_model` triggers `_ensure_forced_plan` +3. Internally calls `apply_enter`, **skipping** `pending_enter`, entering `exploring` directly +4. `PlanToolSet` hides `enter_plan_mode` from the tool schema to avoid redundant HITL + +**AG-UI example (`examples/plan_mode/static/index.html`):** + +When the user toggles Plan mode in the page, each run writes `agent_mode` into the AG-UI request `state` field: + +```javascript +// Must match PlanOptions defaults +const FORCE_ENTER_PLAN_STATE_KEY = "agent_mode"; +const FORCE_ENTER_PLAN_STATE_VALUE = "plan"; + +function buildRunState() { + return { + [FORCE_ENTER_PLAN_STATE_KEY]: + agentMode === "plan" ? FORCE_ENTER_PLAN_STATE_VALUE : "agent", + }; +} + +// RunAgentInput.state = buildRunState() +``` + +After switching to Plan mode, the next user message auto-enters `exploring` without an `enter_plan_mode` confirmation card. See [examples/plan_mode](../../../examples/plan_mode/) for the full demo. + +## Best Practices + +- **Mount read-only exploration first** — `FileToolSet` + `SpawnSubAgentTool(EXPLORE_AGENT, PLAN_AGENT)` before `setup_plan` +- **Extend `write_tool_names`** — if the agent mounts MCP, Skills, or custom write tools, add their `tool.name` values to `PlanOptions.write_tool_names` +- **One `setup_plan` per agent** — avoid duplicate callbacks +- **Pair with AG-UI for HITL** — browser cards for enter / approve / questions are easier than CLI simulation +- **After approval** — use `todo_write` or `task_create` to track implementation; Plan Mode does not replace step tracking +- **Trivial tasks** — awareness prompt allows skipping Plan Mode with an explicit reason; do not force plan for one-line fixes + +## Full Example + +| Example | Description | +| --- | --- | +| [examples/plan_mode](../../../examples/plan_mode/) | Orchestrator + `setup_plan` + AG-UI browser demo with live plan panel and HITL cards | + +Install AG-UI extras: `pip install -e '.[ag-ui]'`, configure `examples/plan_mode/.env`, then run `python3 run_agent_with_agui.py`. diff --git a/docs/mkdocs/en/tool.md b/docs/mkdocs/en/tool.md index b14b235b..6c20ebac 100644 --- a/docs/mkdocs/en/tool.md +++ b/docs/mkdocs/en/tool.md @@ -33,9 +33,9 @@ Agents dynamically use tools through the following steps: | [Streaming Tools](#streaming-tools) | Real-time preview of long text generation | Use StreamingFunctionTool | Code generation, document writing | | [WebFetchTool](#webfetchtool) | Fetch and textify a single public URL | Instantiate WebFetchTool and add to tools | Documentation pages, RFCs, changelogs, news | | [WebSearchTool](#websearchtool) | Public web search engine retrieval | Instantiate WebSearchTool and add to tools | Real-time news, releases, fact/definition lookups | -| [TodoWriteTool](#todowritetool-task-checklist-tool) | Multi-step task planning and progress tracking (full-list replace) | Mount `TodoWriteTool` | Short checklists, no dependency graph, token-insensitive | -| [Task Tool Family](#task-tool-family-structured-task-board) | Structured task board (incremental updates by id + dependencies) | Mount `TaskToolSet` | Long boards, cross-turn tracking, `blockedBy` dependencies | -| [Goal Tool Family](#goal-tool-family-persistent-session-goal) | Single persistent session goal + enforced completion | `setup_goal(agent)` | Cross-turn objectives, host-set goals, no premature finals | +| [TodoWriteTool](./tool_todowrite.md) | Multi-step task planning and progress tracking (full-list replace) | Mount `TodoWriteTool` | Short checklists, no dependency graph, token-insensitive | +| [Task Tool Family](./tool_task.md) | Structured task board (incremental updates by id + dependencies) | Mount `TaskToolSet` | Long boards, cross-turn tracking, `blockedBy` dependencies | +| [Goal Tool Family](./goal.md) | Single persistent session goal + enforced completion | `setup_goal(agent)` | Cross-turn objectives, host-set goals, no premature finals | | [Agent Code Executor](./code_executor.md) | Automatic code generation and execution scenarios, data processing scenarios | Configure CodeExecutor | Automatic API invocation, tabular data processing | --- @@ -2848,354 +2848,3 @@ The example builds four independent Agents and covers the following scenarios: - **Google freshness Agent** (`google_raw_agent`, `dateRestrict=m6` + `dedup_urls=False`): keeps only results indexed within the last 6 months, suitable for "latest / what's new" queries --- - -## TodoWriteTool (Task Checklist Tool) - -`TodoWriteTool` is the framework's built-in **structured task checklist tool**, aligned with Claude Code / DeepAgents `TodoWrite` semantics: the model sends the **complete, updated list** in a single `todo_write` call; the tool validates it, fully replaces the previous list, and persists it to session-level state so plans and progress survive across `Runner.run_async` invocations. - -Best for **fewer steps, no explicit dependency edges, and simple implementation**. If you need server-assigned ids, incremental `taskId` patches, or `blockedBy` / `blocks` dependency orchestration, use the [Task Tool Family](#task-tool-family-structured-task-board) below instead. - -### Features - -- **Full-list replace**: each call passes the complete `todos` array; the new list **fully overwrites** the old one (no smart merge). The only valid way to clear is an explicit `todos: []` -- **Session-level persistence**: the checklist is serialized to JSON in `tool_context.state["todos[:]"]` (default prefix `todos`; **do not** use `temp:` — that prefix is stripped by `BaseSessionService` and is not persisted) -- **Sub-agent isolation**: the state key appends `:` so parent / child agents maintain separate lists -- **Hard contract validation (code-enforced)**: non-empty `content` / `activeForm`, at most one `in_progress`, globally unique `content`; violations return `INVALID_ARGS` / `INVALID_TODOS` -- **Layered prompt guidance**: `DEFAULT_TODO_PROMPT` is auto-injected into the system instruction via `process_request`, separate from hard contracts -- **Structured diff in responses**: on success returns `{message, todos, oldTodos}` for front-end / CLI rendering -- **Optional policy hooks**: read-only `nudge_hooks` can append strategy hints to `message` (must not modify the list) -- **Auto-clear when all done**: with `clear_on_all_done=True` (default), an all-`completed` list is persisted as empty to avoid stale accumulation - -### TodoWriteTool Parameters - -| Parameter | Type | Default | Description | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"todos"` | State key prefix; do not use `temp:` | -| `clear_on_all_done` | `bool` | `True` | Clear persisted list when all items are `completed` | -| `default_nudge` | `str` | built-in text | Base hint appended on every successful response | -| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | Read-only policy hook list | -| `filters_name` / `filters` | — | `None` | Filters forwarded to `BaseTool` | - -**LLM call parameters** (`todo_write`): - -| Parameter | Type | Required | Description | -|------|------|------|------| -| `todos` | `array` | Yes | Full list; each item has `content` (imperative), `activeForm` (in-progress label), `status` (`pending` / `in_progress` / `completed`) | - -**Successful response fields**: - -| Field | Type | Description | -|------|------|------| -| `message` | `str` | Base nudge + hook-appended text | -| `todos` | `array` | Persisted current list | -| `oldTodos` | `array \| null` | Previous list (`null` on first write) | - -### Usage - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TodoWriteTool - -agent = LlmAgent( - name="todo_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="You are a planning assistant; use todo_write for multi-step tasks.", - tools=[TodoWriteTool()], -) -``` - -Read back the persisted checklist (REST / audit): - -```python -from trpc_agent_sdk.tools import get_todos, render_todos - -todos = get_todos(session, branch=agent.name) -print(render_todos(todos)) # ✅ / 🔄 / ⬜ plain-text checklist -``` - -### TodoWriteTool vs Task Tool Family - -| Dimension | `TodoWriteTool` | `TaskToolSet` | -| --- | --- | --- | -| Tool count | 1 (`todo_write`) | 4 (`task_create` / `task_update` / `task_get` / `task_list`) | -| Update style | Full-list replace | Incremental patch by `taskId` | -| Item identity | `content` (unique key) | `id` (server-assigned) | -| Dependencies | None | `blockedBy` / `blocks`; upstream `completed` auto-unblocks | -| State key | `todos[:branch]` | `tasks[:branch]` | -| Parallel tool calls | Full-list overwrite, natural last-write-wins | `task_store_lock` serializes RMW | - -> **Mount one or the other**; mounting both tends to confuse the model. - -### TodoWriteTool Complete Example - -See [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py): multiple turns in one session — plan → complete items step by step — with `get_todos` reading back the persisted list after each turn. - ---- - -## Task Tool Family (Structured Task Board) - -`TaskToolSet` exposes four tools — `task_create`, `task_update`, `task_get`, `task_list` — aligned with Claude Code v2.1.142+ structured Task capabilities. Unlike `TodoWriteTool`'s full-list replace, the Task family uses **incremental updates by server-assigned `id`**: creation returns an id; later `task_update` patches status, fields, or dependency edges locally. - -The entire board is serialized as a **single JSON blob** in `tool_context.state["tasks[:]"]`, surviving across turns. `highwatermark` records the highest id ever assigned; soft-deleted tasks (`status: deleted`) **never reuse ids**. - -### Features - -- **Incremental updates**: `task_create` assigns ids; `task_update` patches by `taskId` without resending the whole board -- **Dependency orchestration**: `addBlockedBy` / `removeBlockedBy` (and `addBlocks` / `removeBlocks`) maintain bidirectional edges; upstream `completed` removes ids from downstream `blockedBy` and returns `unblocked` -- **Token optimization**: `task_list` returns summaries only (omits `description`); use `task_get` for full detail -- **Hard contract validation**: non-empty `subject`, valid status, existing dependency refs, **acyclic** graph (`detect_cycle`), default **at most one `in_progress`** (`enforce_single_in_progress`, can disable) -- **Concurrency safety**: `_TaskToolBase` wraps load → mutate → save in `task_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` -- **Auto prompt injection**: `DEFAULT_TASK_PROMPT` injected once when multiple tools are mounted - -### TaskToolSet Constructor Parameters - -| Parameter | Type | Default | Description | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"tasks"` | State key prefix; do not use `temp:` | -| `enforce_single_in_progress` | `bool` | `True` | Reject a second `in_progress` when one already exists | -| `inject_prompt` | `bool` | `True` | Inject `DEFAULT_TASK_PROMPT` into system instruction | - -### LLM Parameters for the Four Tools - -**`task_create`** - -| Parameter | Required | Description | -|------|------|------| -| `subject` | Yes | Short imperative title | -| `description` | No | Free-text detail | -| `activeForm` | No | In-progress label | -| `metadata` | No | Extension key-value map | - -Returns `{task: {id, subject}, message}`. - -**`task_update`** - -| Parameter | Required | Description | -|------|------|------| -| `taskId` | Yes | Task id to update | -| `status` | No | `pending` / `in_progress` / `completed` / `deleted` | -| `subject` / `description` / `activeForm` / `owner` / `metadata` | No | Scalar field patches | -| `addBlockedBy` / `removeBlockedBy` | No | Upstream dependency id lists | -| `addBlocks` / `removeBlocks` | No | Downstream blocked-id lists | - -Returns `{task, unblocked, message}`; `unblocked` lists pending task ids unblocked by this completion. - -**`task_get`**: `taskId` (required) → full record including `description`. - -**`task_list`**: optional `includeDeleted`; returns `{tasks, stats}` with summaries (no `description`). - -**Common error codes**: `INVALID_ARGS`, `INVALID_DEPENDENCY`, `INVALID_STATUS`, `NOT_FOUND`. - -### Usage - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TaskToolSet - -agent = LlmAgent( - name="task_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="Use task_create / task_update to maintain the board for multi-step projects.", - tools=[TaskToolSet()], - # With parallel_tool_calls=True, concurrent task tools on the same board are serialized by task_store_lock -) -``` - -Read back the persisted board (REST / audit / demo wrap-up): - -```python -from trpc_agent_sdk.tools import get_task_store, render_task_list - -store = get_task_store(session, branch=agent.name) -print(render_task_list(store)) -# ✅ #1 completed -# 🔄 #2 in progress -# ⬜ #3 pending (blocked by: 2) -``` - -### Dependency and Unblock Example - -```text -#1 Design schema - ├──→ #2 Implement API ──→ #3 Unit tests - └──→ #4 Write docs - -#1 completed → unblocked: ['2', '4'] -#2 completed → unblocked: ['3'] -``` - -### Task Tool Family Best Practices - -- **Separate planning from execution**: `task_create` + `addBlockedBy` first, then `in_progress` → `completed` item by item -- **Do not invent ids**: use only ids returned by `task_create` -- **Parallel calls**: with `parallel_tool_calls=True`, concurrent `task_create` / `task_update` on the same board are serialized by the lock; different `branch` values still run in parallel -- **Pick TodoWrite or Task**: long boards + dependencies → Task; short checklists → TodoWrite - -### Task Tool Family Complete Examples - -| Example | Description | -| --- | --- | -| [examples/task_tools](../../../examples/task_tools/) | Multi-turn dialog: dependency graph, step-by-step completion, `get_task_store` across turns | -| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | Validates `parallel_tool_calls` + `task_store_lock` (Phase 1–2 needs no API key) | - ---- - -## Goal Tool Family (Persistent Session Goal) - -`GoalToolSet` exposes three tools — `create_goal`, `get_goal`, `update_goal` — aligned with Claude Code **Session Goal** capabilities. Unlike `TodoWriteTool` (multi-item checklist) and `TaskToolSet` (multi-task board), Goal maintains **at most one** persistent objective per session branch: while status is `active`, a response that **looks like a final answer** does **not** mean the work is done — the model must keep working or explicitly call `update_goal('complete' | 'blocked')`. - -The goal is serialized as a **single JSON blob** (`GoalRecord`) in `tool_context.state["goal[:]"]`, surviving across `Runner.run_async` calls. Beyond the three model tools, the full capability requires `setup_goal()` to mount **enforcement callbacks** (`before_model` / `after_model`) that intercept premature final responses and re-run within the **same invocation**. - -### Features - -- **Single-goal contract**: one `GoalRecord` per branch (`objective` + three states `active` / `complete` / `blocked`); `complete` / `blocked` are **irreversible** terminal states -- **Cross-turn persistence**: persisted via function-response state deltas; **do not** use the `temp:` prefix -- **Sub-agent isolation**: state key appends `:` -- **Enforced completion**: while `active`, `after_model` detects premature finals (no tool call, visible text, non-partial), suppresses them, and re-runs in the same invocation; `before_model` injects a user-role nudge -- **Fail-open budget**: after `max_retries` (default 3) interceptions, the final response is allowed so the loop cannot spin forever; counters live in invocation-scoped `agent_context.metadata` and are not persisted -- **Two creation paths**: - - **Model side**: `create_goal(objective=...)` — LLM creates after judging a multi-step task - - **Host side**: `start_goal(session_service, ...)` — application writes the goal before the first turn; the model does not call `create_goal` -- **Layered prompt guidance**: `DEFAULT_GUIDANCE` injected into system instruction via `before_model` when `inject_guidance=True`; hard rules enforced by store validation + callbacks -- **Concurrency safety**: `_GoalToolBase` wraps load → mutate → save in `goal_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` - -### Relationship to Todo / Task - -| Dimension | `TodoWriteTool` | `TaskToolSet` | Goal Tool Family | -| --- | --- | --- | --- | -| Granularity | Multi-item checklist | Multi-task board + deps | **Single** session objective | -| Update style | Full-list replace | Incremental by `taskId` | `create_goal` / `update_goal` | -| Can finish while incomplete? | Prompt guidance | Prompt guidance | **Callback enforcement** | -| State key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | -| Typical use | Step visibility, short lists | Long boards, dependencies | Whether the whole job is truly done | - -> Todo / Task handle **step decomposition**; Goal handles the **overall completion contract**. They can be combined, but avoid mounting too many planning tools at once. - -### GoalOptions Constructor Parameters - -Configure via `setup_goal(agent, GoalOptions(...))`: - -| Parameter | Type | Default | Description | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"goal"` | State key prefix; do not use `temp:` | -| `inject_guidance` | `bool` | `True` | Inject `DEFAULT_GUIDANCE` into system instruction in `before_model` | -| `guidance` | `str` | `DEFAULT_GUIDANCE` | Long guidance text (serial goal-tool calls, etc.) | -| `max_retries` | `int` | `3` | Same-invocation budget for intercepting premature finals; fail-open when exhausted | -| `nudge_template` | `str` | `DEFAULT_NUDGE` | User-role reminder after interception; supports `{attempt}` / `{max_retries}` / `{objective}` | -| `on_retry` | `Callable[[RetryEvent], None]` | `None` | Observability callback on each interception or budget exhaustion | - -Mounting only `GoalToolSet()` without enforcement gives model-facing tools but **not** "no final while active". - -### LLM Parameters for the Three Tools - -**`create_goal`** - -| Parameter | Required | Description | -|------|------|------| -| `objective` | Yes | Completion criteria — what "done" concretely means | - -Success: `{message, goal}`; if an `active` goal already exists: `{error: "INVALID_STATE: ..."}`. - -**`get_goal`** - -No parameters. With a goal: `{message, goal}`; without: `{message: "No session goal is set."}`. - -**`update_goal`** - -| Parameter | Required | Description | -|------|------|------| -| `status` | Yes | `complete` (objective met) or `blocked` (same blocker repeats; cannot proceed without user input) | - -Success: `{message, goal}`; no active goal or already terminal: `{error: "INVALID_STATE: ..."}`. - -**`GoalRecord` fields** (persisted with camelCase JSON aliases): - -| Field | Description | -|------|------| -| `id` | Server-assigned uuid | -| `objective` | Completion criteria text | -| `status` | `active` / `complete` / `blocked` | -| `createdAtUnix` / `updatedAtUnix` | Created / last-updated time (unix seconds) | -| `terminalAtUnix` | Time entered a terminal state (optional) | - -### Enforcement Workflow - -```text -Model outputs final text (no tool call, goal still active) - ↓ -after_model classifies as premature final - ↓ -Suppress final (not committed as answer), retry_count += 1 -before_model injects nudge, same invocation continues agent loop - ↓ -retry_count >= max_retries → fail-open, on_retry(reason="exhausted") -``` - -Interception condition (`_is_premature_final`): non-partial, no error, visible text in content, and **no** `function_call` / `function_response`. - -### Usage - -Recommended one-line mount of tools + callbacks: - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal - -def on_retry(event: RetryEvent) -> None: - if event.reason == "blocked": - print(f"Premature final intercepted (attempt {event.attempt_number}/{event.max_retries})") - -agent = LlmAgent( - name="goal_agent", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="Use goal tools to track completion for multi-step engineering tasks.", - tools=[...], # Business tools, e.g. BashTool / WriteTool -) -setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) -``` - -Host pre-injects a goal before the first turn (model does not call `create_goal`): - -```python -from trpc_agent_sdk.tools.goal_tools import start_goal - -goal = await start_goal( - session_service, - app_name="my_app", - user_id="user_1", - session_id=session_id, - objective="Create notes/ with summary.txt and example.py in the current directory", - agent_name=agent.name, # Match LlmAgent.name for branch isolation -) -``` - -Read back the persisted goal (REST / audit / demo wrap-up): - -```python -from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal - -goal = get_goal_record(session, branch=agent.name) -print(render_goal(goal)) -# ✅ Goal [complete] -# objective: ... -# created: 1782893110 -# terminal: 1782893116 -``` - -### Goal Tool Family Best Practices - -- **Use `setup_goal`, not `GoalToolSet` alone**: only callbacks enforce "no final while active" -- **Model vs host**: slash commands, `/goal`, config-driven tasks → `start_goal()`; let the model judge multi-step work → `create_goal` -- **One goal tool per response**: `DEFAULT_GUIDANCE` requires serial semantics; do not call `create_goal` and `update_goal` in the same turn -- **Use `blocked` sparingly**: only when the same blocker repeats across attempts and user input or external state change is required; do not mark blocked because work is hard, slow, or incomplete -- **Observability**: use `on_retry` to log premature-final interceptions and budget exhaustion when tuning prompt or `max_retries` -- **Division of labor with Todo / Task**: Todo / Task show steps and dependencies; Goal constrains whether the whole job is truly finished - -### Goal Tool Family Complete Example - -| Example | Description | -| --- | --- | -| [examples/goal_tools](../../../examples/goal_tools/) | Case 1: model `create_goal`; Case 2: host `start_goal` pre-injection; demonstrates enforcement interception and `update_goal(complete)` | diff --git a/docs/mkdocs/en/tool_task.md b/docs/mkdocs/en/tool_task.md new file mode 100644 index 00000000..4e0f8cf4 --- /dev/null +++ b/docs/mkdocs/en/tool_task.md @@ -0,0 +1,108 @@ +## Task Tool Family (Structured Task Board) + +`TaskToolSet` exposes four tools — `task_create`, `task_update`, `task_get`, `task_list` — aligned with Claude Code v2.1.142+ structured Task capabilities. Unlike `TodoWriteTool`'s full-list replace, the Task family uses **incremental updates by server-assigned `id`**: creation returns an id; later `task_update` patches status, fields, or dependency edges locally. + +The entire board is serialized as a **single JSON blob** in `tool_context.state["tasks[:]"]`, surviving across turns. `highwatermark` records the highest id ever assigned; soft-deleted tasks (`status: deleted`) **never reuse ids**. + +### Features + +- **Incremental updates**: `task_create` assigns ids; `task_update` patches by `taskId` without resending the whole board +- **Dependency orchestration**: `addBlockedBy` / `removeBlockedBy` (and `addBlocks` / `removeBlocks`) maintain bidirectional edges; upstream `completed` removes ids from downstream `blockedBy` and returns `unblocked` +- **Token optimization**: `task_list` returns summaries only (omits `description`); use `task_get` for full detail +- **Hard contract validation**: non-empty `subject`, valid status, existing dependency refs, **acyclic** graph (`detect_cycle`), default **at most one `in_progress`** (`enforce_single_in_progress`, can disable) +- **Concurrency safety**: `_TaskToolBase` wraps load → mutate → save in `task_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` +- **Auto prompt injection**: `DEFAULT_TASK_PROMPT` injected once when multiple tools are mounted + +### TaskToolSet Constructor Parameters + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"tasks"` | State key prefix; do not use `temp:` | +| `enforce_single_in_progress` | `bool` | `True` | Reject a second `in_progress` when one already exists | +| `inject_prompt` | `bool` | `True` | Inject `DEFAULT_TASK_PROMPT` into system instruction | + +### LLM Parameters for the Four Tools + +**`task_create`** + +| Parameter | Required | Description | +|------|------|------| +| `subject` | Yes | Short imperative title | +| `description` | No | Free-text detail | +| `activeForm` | No | In-progress label | +| `metadata` | No | Extension key-value map | + +Returns `{task: {id, subject}, message}`. + +**`task_update`** + +| Parameter | Required | Description | +|------|------|------| +| `taskId` | Yes | Task id to update | +| `status` | No | `pending` / `in_progress` / `completed` / `deleted` | +| `subject` / `description` / `activeForm` / `owner` / `metadata` | No | Scalar field patches | +| `addBlockedBy` / `removeBlockedBy` | No | Upstream dependency id lists | +| `addBlocks` / `removeBlocks` | No | Downstream blocked-id lists | + +Returns `{task, unblocked, message}`; `unblocked` lists pending task ids unblocked by this completion. + +**`task_get`**: `taskId` (required) → full record including `description`. + +**`task_list`**: optional `includeDeleted`; returns `{tasks, stats}` with summaries (no `description`). + +**Common error codes**: `INVALID_ARGS`, `INVALID_DEPENDENCY`, `INVALID_STATUS`, `NOT_FOUND`. + +### Usage + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TaskToolSet + +agent = LlmAgent( + name="task_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="Use task_create / task_update to maintain the board for multi-step projects.", + tools=[TaskToolSet()], + # With parallel_tool_calls=True, concurrent task tools on the same board are serialized by task_store_lock +) +``` + +Read back the persisted board (REST / audit / demo wrap-up): + +```python +from trpc_agent_sdk.tools import get_task_store, render_task_list + +store = get_task_store(session, branch=agent.name) +print(render_task_list(store)) +# ✅ #1 completed +# 🔄 #2 in progress +# ⬜ #3 pending (blocked by: 2) +``` + +### Dependency and Unblock Example + +```text +#1 Design schema + ├──→ #2 Implement API ──→ #3 Unit tests + └──→ #4 Write docs + +#1 completed → unblocked: ['2', '4'] +#2 completed → unblocked: ['3'] +``` + +### Task Tool Family Best Practices + +- **Separate planning from execution**: `task_create` + `addBlockedBy` first, then `in_progress` → `completed` item by item +- **Do not invent ids**: use only ids returned by `task_create` +- **Parallel calls**: with `parallel_tool_calls=True`, concurrent `task_create` / `task_update` on the same board are serialized by the lock; different `branch` values still run in parallel +- **Pick TodoWrite or Task**: long boards + dependencies → Task; short checklists → TodoWrite + +### Task Tool Family Complete Examples + +| Example | Description | +| --- | --- | +| [examples/task_tools](../../../examples/task_tools/) | Multi-turn dialog: dependency graph, step-by-step completion, `get_task_store` across turns | +| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | Validates `parallel_tool_calls` + `task_store_lock` (Phase 1–2 needs no API key) | + +--- diff --git a/docs/mkdocs/en/tool_todowrite.md b/docs/mkdocs/en/tool_todowrite.md new file mode 100644 index 00000000..6d253094 --- /dev/null +++ b/docs/mkdocs/en/tool_todowrite.md @@ -0,0 +1,83 @@ +## TodoWriteTool (Task Checklist Tool) + +`TodoWriteTool` is the framework's built-in **structured task checklist tool**, aligned with Claude Code / DeepAgents `TodoWrite` semantics: the model sends the **complete, updated list** in a single `todo_write` call; the tool validates it, fully replaces the previous list, and persists it to session-level state so plans and progress survive across `Runner.run_async` invocations. + +Best for **fewer steps, no explicit dependency edges, and simple implementation**. If you need server-assigned ids, incremental `taskId` patches, or `blockedBy` / `blocks` dependency orchestration, use the [Task Tool Family](./tool_task.md) instead. + +### Features + +- **Full-list replace**: each call passes the complete `todos` array; the new list **fully overwrites** the old one (no smart merge). The only valid way to clear is an explicit `todos: []` +- **Session-level persistence**: the checklist is serialized to JSON in `tool_context.state["todos[:]"]` (default prefix `todos`; **do not** use `temp:` — that prefix is stripped by `BaseSessionService` and is not persisted) +- **Sub-agent isolation**: the state key appends `:` so parent / child agents maintain separate lists +- **Hard contract validation (code-enforced)**: non-empty `content` / `activeForm`, at most one `in_progress`, globally unique `content`; violations return `INVALID_ARGS` / `INVALID_TODOS` +- **Layered prompt guidance**: `DEFAULT_TODO_PROMPT` is auto-injected into the system instruction via `process_request`, separate from hard contracts +- **Structured diff in responses**: on success returns `{message, todos, oldTodos}` for front-end / CLI rendering +- **Optional policy hooks**: read-only `nudge_hooks` can append strategy hints to `message` (must not modify the list) +- **Auto-clear when all done**: with `clear_on_all_done=True` (default), an all-`completed` list is persisted as empty to avoid stale accumulation + +### TodoWriteTool Parameters + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"todos"` | State key prefix; do not use `temp:` | +| `clear_on_all_done` | `bool` | `True` | Clear persisted list when all items are `completed` | +| `default_nudge` | `str` | built-in text | Base hint appended on every successful response | +| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | Read-only policy hook list | +| `filters_name` / `filters` | — | `None` | Filters forwarded to `BaseTool` | + +**LLM call parameters** (`todo_write`): + +| Parameter | Type | Required | Description | +|------|------|------|------| +| `todos` | `array` | Yes | Full list; each item has `content` (imperative), `activeForm` (in-progress label), `status` (`pending` / `in_progress` / `completed`) | + +**Successful response fields**: + +| Field | Type | Description | +|------|------|------| +| `message` | `str` | Base nudge + hook-appended text | +| `todos` | `array` | Persisted current list | +| `oldTodos` | `array \| null` | Previous list (`null` on first write) | + +### Usage + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TodoWriteTool + +agent = LlmAgent( + name="todo_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="You are a planning assistant; use todo_write for multi-step tasks.", + tools=[TodoWriteTool()], +) +``` + +Read back the persisted checklist (REST / audit): + +```python +from trpc_agent_sdk.tools import get_todos, render_todos + +todos = get_todos(session, branch=agent.name) +print(render_todos(todos)) # ✅ / 🔄 / ⬜ plain-text checklist +``` + +### TodoWriteTool vs Task Tool Family + +| Dimension | `TodoWriteTool` | `TaskToolSet` | +| --- | --- | --- | +| Tool count | 1 (`todo_write`) | 4 (`task_create` / `task_update` / `task_get` / `task_list`) | +| Update style | Full-list replace | Incremental patch by `taskId` | +| Item identity | `content` (unique key) | `id` (server-assigned) | +| Dependencies | None | `blockedBy` / `blocks`; upstream `completed` auto-unblocks | +| State key | `todos[:branch]` | `tasks[:branch]` | +| Parallel tool calls | Full-list overwrite, natural last-write-wins | `task_store_lock` serializes RMW | + +> **Mount one or the other**; mounting both tends to confuse the model. + +### TodoWriteTool Complete Example + +See [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py): multiple turns in one session — plan → complete items step by step — with `get_todos` reading back the persisted list after each turn. + +--- diff --git a/docs/mkdocs/zh/goal.md b/docs/mkdocs/zh/goal.md new file mode 100644 index 00000000..742114b0 --- /dev/null +++ b/docs/mkdocs/zh/goal.md @@ -0,0 +1,157 @@ +## Goal 工具族(持久会话目标) + +`GoalToolSet` 暴露三个工具——`create_goal`、`get_goal`、`update_goal`——对齐 Claude Code 的 **Session Goal** 能力。与 `TodoWriteTool`(多行待办)和 `TaskToolSet`(多任务看板)不同,Goal 在每个 session branch 上**至多只有一个**持久目标:在目标为 `active` 期间,模型给出「看起来像最终答案」的文本**不算完成**——必须继续执行,或显式调用 `update_goal('complete' | 'blocked')` 收尾。 + +目标序列化为**单个 JSON blob**(`GoalRecord`)写入 `tool_context.state["goal[:]"]`,跨 `Runner.run_async` 调用存活。除三个模型工具外,完整能力还需通过 `setup_goal()` 挂载一对 **enforcement callbacks**(`before_model` / `after_model`),在**同一次 invocation 内**拦截过早的最终回复并自动重试。 + +### 功能特性 + +- **单目标契约**:每个 branch 一个 `GoalRecord`(`objective` + 三态 `active` / `complete` / `blocked`);`complete` / `blocked` 为**不可逆**终态 +- **跨轮持久化**:随 function-response 的 state delta 落库;**勿用** `temp:` 前缀 +- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立目标 +- **强制收尾(enforcement)**:目标 `active` 时,`after_model` 检测「无 tool call、有可见文本、非 partial」的过早 final,抑制该回复并在同 invocation 内 re-run;`before_model` 注入 user-role nudge +- **fail-open 预算**:`max_retries`(默认 3)次拦截后放行最终回复,避免无限循环;计数器存在 invocation 级 `agent_context.metadata`,不持久化 +- **双入口创建**: + - **模型侧**:`create_goal(objective=...)` —— LLM 判断多步任务后自主创建 + - **宿主侧**:`start_goal(session_service, ...)` —— 应用层在首轮前写入 session,模型无需调用 `create_goal` +- **Prompt 引导分层**:`DEFAULT_GUIDANCE` 在目标 active 时经 `before_model` 注入 system instruction(`inject_guidance=True`);硬约束由 store 校验 + callback 共同保证 +- **并发安全**:`_GoalToolBase` 在 load → mutate → save 外包 `goal_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` + +### 与 Todo / Task 的关系 + +| 维度 | `TodoWriteTool` | `TaskToolSet` | Goal 工具族 | +| --- | --- | --- | --- | +| 粒度 | 多行待办清单 | 多任务看板 + 依赖 | **单个**会话目标 | +| 更新方式 | 整表替换 | 按 `taskId` 增量 | `create_goal` / `update_goal` | +| 未完成能否收尾 | Prompt 引导 | Prompt 引导 | **callback 强制拦截** | +| state key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | +| 典型用途 | 步骤可视、短清单 | 长看板、依赖编排 | 整件事是否算做完 | + +> Todo / Task 管「步骤分解」,Goal 管「整体完成契约」。可组合使用,但避免让模型同时混用过多规划工具。 + +### GoalOptions 构造参数 + +通过 `setup_goal(agent, GoalOptions(...))` 配置: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"goal"` | state key 前缀;勿使用 `temp:` | +| `inject_guidance` | `bool` | `True` | 是否在 `before_model` 向 system instruction 注入 `DEFAULT_GUIDANCE` | +| `guidance` | `str` | `DEFAULT_GUIDANCE` | 注入的长文案(含串行调用 goal 工具等约定) | +| `max_retries` | `int` | `3` | 同 invocation 内拦截过早 final 的预算;耗尽后 fail-open | +| `nudge_template` | `str` | `DEFAULT_NUDGE` | 拦截后以 user-role 追加的提醒模板(支持 `{attempt}` / `{max_retries}` / `{objective}`) | +| `on_retry` | `Callable[[RetryEvent], None]` | `None` | 每次拦截或预算耗尽时的可观测回调 | + +仅挂载模型工具、不要 enforcement 时,可直接 `tools=[GoalToolSet()]`,但不具备「未完成不许收尾」能力。 + +### 三个工具的 LLM 参数概要 + +**`create_goal`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `objective` | 是 | 完成标准——「done」具体指什么 | + +成功返回 `{message, goal}`;若已有 `active` 目标返回 `{error: "INVALID_STATE: ..."}`。 + +**`get_goal`** + +无参数。有目标时返回 `{message, goal}`;无目标时返回 `{message: "No session goal is set."}`。 + +**`update_goal`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `status` | 是 | `complete`(目标已达成)或 `blocked`(同一阻塞条件反复出现、无用户输入无法继续) | + +成功返回 `{message, goal}`;无 active 目标或已是终态时返回 `{error: "INVALID_STATE: ..."}`。 + +**`GoalRecord` 字段**(JSON 使用 camelCase 别名持久化): + +| 字段 | 说明 | +|------|------| +| `id` | 服务端分配的 uuid | +| `objective` | 完成标准文本 | +| `status` | `active` / `complete` / `blocked` | +| `createdAtUnix` / `updatedAtUnix` | 创建 / 最后更新时间(unix 秒) | +| `terminalAtUnix` | 进入终态的时间(可选) | + +### enforcement 工作流程 + +```text +模型输出 final 文本(无 tool call,goal 仍 active) + ↓ +after_model 判定为 premature final + ↓ +抑制该 final(不提交为答案),retry_count += 1 +before_model 注入 nudge,同 invocation 继续 agent loop + ↓ +retry_count >= max_retries → fail-open,on_retry(reason="exhausted") +``` + +拦截条件(`_is_premature_final`):非 partial、无 error、content 含可见文本,且**不含** `function_call` / `function_response`。 + +### 使用方式 + +推荐一行挂载工具 + callbacks: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal + +def on_retry(event: RetryEvent) -> None: + if event.reason == "blocked": + print(f"拦截过早收尾 (attempt {event.attempt_number}/{event.max_retries})") + +agent = LlmAgent( + name="goal_agent", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="多步工程任务请用 goal 工具跟踪完成状态。", + tools=[...], # 业务工具,如 BashTool / WriteTool +) +setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) +``` + +宿主在首轮前预注入目标(模型不调用 `create_goal`): + +```python +from trpc_agent_sdk.tools.goal_tools import start_goal + +goal = await start_goal( + session_service, + app_name="my_app", + user_id="user_1", + session_id=session_id, + objective="在当前目录创建 notes/ 并写入 summary.txt 与 example.py", + agent_name=agent.name, # 与 LlmAgent.name 一致,用于 branch 隔离 +) +``` + +读回持久化目标(REST / 审计 / demo 收尾): + +```python +from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal + +goal = get_goal_record(session, branch=agent.name) +print(render_goal(goal)) +# ✅ Goal [complete] +# objective: ... +# created: 1782893110 +# terminal: 1782893116 +``` + +### Goal 工具族最佳实践 + +- **用 `setup_goal` 而非只挂 `GoalToolSet`**:只有 callbacks 才能实现「active 期间不许 final」 +- **模型侧 vs 宿主侧**:Slash command、`/goal`、配置驱动任务用 `start_goal()`;让模型自主判断多步任务时用 `create_goal` +- **一次响应只调一个 goal 工具**:`DEFAULT_GUIDANCE` 要求串行语义;不要同轮 `create_goal` + `update_goal` +- **`blocked` 慎用**:仅当同一阻塞条件跨多次尝试仍无法推进、且需要用户输入或外部状态变化时使用;不要因为任务难、慢或不完整就标记 blocked +- **可观测性**:通过 `on_retry` 记录 `⚡ Premature final intercepted` 与预算耗尽,便于调优 prompt 或 `max_retries` +- **与 Todo / Task 分工**:Todo / Task 展示步骤与依赖;Goal 约束「整件事是否已真正完成」 + +### Goal 工具族完整示例 + +| 示例 | 说明 | +| --- | --- | +| [examples/goal_tools](../../../examples/goal_tools/) | Case 1:模型 `create_goal`;Case 2:宿主 `start_goal` 预注入;演示 enforcement 拦截与 `update_goal(complete)` | diff --git a/docs/mkdocs/zh/index.md b/docs/mkdocs/zh/index.md index fb2feccc..270e6f11 100644 --- a/docs/mkdocs/zh/index.md +++ b/docs/mkdocs/zh/index.md @@ -16,6 +16,7 @@ - [Memory](./memory.md):存储和检索长期记忆。 - [Knowledge](./knowledge.md):基于 LangChain 组件构建 RAG 工作流。 - [多 Agent](./multi_agents.md):编排多个 Agent 完成复杂任务。 +- [Plan Mode](./plan.md):先规划后实施,写工具 gate + HITL 审批。 - [Evaluation](./evaluation.md):评测 Agent 行为和回复质量。 源码与示例请参考 [GitHub 仓库](https://github.com/trpc-group/trpc-agent-python)。 diff --git a/docs/mkdocs/zh/plan.md b/docs/mkdocs/zh/plan.md new file mode 100644 index 00000000..349ad32d --- /dev/null +++ b/docs/mkdocs/zh/plan.md @@ -0,0 +1,343 @@ +# Plan Mode(计划模式) + +Plan Mode 为 `LlmAgent` 提供 **先规划、后实施** 的工作流:规划阶段模型只能使用只读工具并撰写计划文档;所有具备副作用的写工具,在人工通过 HITL 审批计划之前会被代码级 gate 拦截。 + +常与 `SpawnSubAgentTool`(`EXPLORE_AGENT` / `PLAN_AGENT`)配合做代码调研与方案设计,批准后可搭配 `TodoWriteTool` 或 `TaskToolSet` 跟踪实施进度。 + +## 解决什么问题 + +普通 coding agent 容易一上来就改文件、边写边改方向。Plan Mode 把流程拆成两段: + +1. **规划阶段** —— 用只读工具(`Read` / `Grep` / `Glob`)、只读子 agent(`Explore` / `Plan`)调研代码,通过 `update_plan_content` 写计划、用 `ask_user_question` 澄清需求;`Write` / `Edit` / `Bash`、`todo_write`、`task_create` 等副作用工具被 gate。 +2. **实施阶段** —— 人工批准后写工具解锁,模型按批准的计划落地实现。 + +三个需要人工介入的节点——`enter_plan_mode`、`exit_plan_mode`、`ask_user_question`——均为 `LongRunningFunctionTool`,执行会暂停,直到宿主以 tool function response 续跑。通用 HITL 机制见 [Human in the Loop](./human_in_the_loop.md)。 + +## 为什么这很重要 + +AI 编码 Agent 最大的风险之一不是写错代码,而是**写对了错误的东西**。当用户说「重构认证模块」时,Agent 可能选择 JWT 方案,而用户心中想的是 OAuth2。如果 Agent 直接开始实现,等用户发现方向错误时,已经修改了十几个文件。 + +Plan Mode 解决的是**意图对齐**问题:在 Agent 动手修改代码之前,先让它探索代码库、制定计划、获得用户审批。这不是简单的「先问再做」——它是一套完整的状态机,涉及: + +- 写工具 gate(权限级行为约束) +- 计划文档持久化(对齐载体) +- 工作流提示词注入 +- 进入 / 退出两处 HITL 审批 +- 与 UI Plan 开关(`agent_mode=plan`)的联动 + +## 解决方案:四步闭环 + +Plan Mode 在对话中引入**只读阶段**,通过 `enter_plan_mode` 与 `exit_plan_mode` 两个长运行工具形成闭环: + +| 步骤 | 名称 | 行为 | +| --- | --- | --- | +| 1 | **进入计划模式** | 模型判断任务需要规划,或用户在 UI 选择 Plan Mode 后,调用 `enter_plan_mode`(HITL:需用户确认进入) | +| 2 | **探索阶段** | 权限约束为只读:仅允许 `Read` / `Grep` / `Glob`、只读子 agent(`Explore` / `Plan`)及 `update_plan_content`;所有写操作在 `before_tool` 被拦截 | +| 3 | **提交方案审批** | 探索完成后调用 `exit_plan_mode`,将计划文档提交给用户审阅(HITL:需用户批准或拒绝) | +| 4 | **恢复执行** | 用户批准后状态变为 `approved`,写工具 gate 关闭,Agent 按批准计划以完整工具权限实施 | + +```mermaid +flowchart LR + A["① enter_plan_mode
进入计划模式"] --> B["② 探索阶段
只读工具集"] + B --> C["update_plan_content
撰写计划"] + C --> D["③ exit_plan_mode
提交审批"] + D --> E{用户批准?} + E -->|拒绝| C + E -->|批准| F["④ approved
恢复写权限"] + F --> G["按批准计划实施"] +``` + +```text +用户 / 模型 HITL 只读 gate HITL 全权限 + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +enter_plan_mode ──批准──▶ exploring/drafting ──写计划──▶ exit_plan_mode ──批准──▶ approved → Write/Edit/... +``` + +## 关键设计决策 + +从工程角度看,Plan Mode 体现三个核心设计决策(与 Claude Code Plan Mode 理念对齐,在 tRPC-Agent-Python 中的落地方式如下): + +### 1. 权限模式作为行为约束 + +进入 Plan Mode 后,模型的工具集被限制为只读——**不是**靠提示词「请不要修改文件」,而是通过 `before_tool` 在工具调用前拦截写入操作(`PLAN_MODE_GATE` 错误)。子 agent 同样受约束:`spawn_subagent` 仅允许 `Explore` / `Plan` 原型,避免继承父 agent 的写工具面。 + +### 2. 计划文档作为对齐载体 + +计划不是停留在对话里的零散文字,而是持久化的 **Markdown 制品**(`PlanRecord.content`),写入 session state(`plan[:branch]`),跨轮 `Runner.run_async` 存活。用户可在审批环节编辑计划(`exit_plan_mode` 续跑时传入 `content`);AG-UI 通过 `STATE_SNAPSHOT` 实时展示计划面板,便于远程会话与本地 UI 对齐。 + +### 3. 状态机而非布尔开关 + +Plan Mode 不是简单的 `isPlanMode` 标志,而是包含进入、探索、起草、审批、批准、恢复的完整状态转换链(`PlanStatus`),每个转换都有副作用: + +| 转换 | 副作用 | +| --- | --- | +| `enter_plan_mode` 批准 | 创建 `PlanRecord`,进入 `exploring`,开启写 gate | +| `update_plan_content` | `exploring` → `drafting`,追加/替换计划正文 | +| `exit_plan_mode` | → `pending_approval`,暂停等待审批 | +| 批准 | → `approved`,关闭写 gate,释放 plan 锁 | +| 拒绝 | → `drafting`,允许修订后再次提交 | + +## 架构 + +``` +orchestrator(LlmAgent + setup_plan) +├── 业务工具(如 FileToolSet、SpawnSubAgentTool、TodoWriteTool) +└── PlanToolSet(由 setup_plan 挂载) + ├── enter_plan_mode (LongRunningFunctionTool — HITL) + ├── update_plan_content + ├── exit_plan_mode (LongRunningFunctionTool — HITL) + └── ask_user_question (LongRunningFunctionTool — HITL) + +_PlanCallbacks(before_model / before_tool) +├── 注入 plan / awareness 提示词 +├── 处理 HITL 续跑 payload +├── 根据 session state 信号自动进入 Plan Mode +└── gate 激活期间拦截写工具 +``` + +- 计划文档持久化在**主 agent 的 session** 中,key 为 `state["plan[:]"]`(默认前缀 `plan`)。 +- 被 spawn 的子 agent 只返回文本,不直接修改父 agent 的 plan 状态。 + +## 状态机 + +| 状态 | 含义 | 写 gate | +| --- | --- | --- | +| `pending_enter` | 等待人工确认进入 Plan Mode | 开启 | +| `exploring` | 只读探索中 | 开启 | +| `drafting` | 正在撰写计划 | 开启 | +| `pending_approval` | 计划已提交,等待审批 | 开启 | +| `approved` | 人工批准,可开始实施 | **关闭** | + +典型流转: + +```text +enter_plan_mode(HITL)→ exploring → update_plan_content → drafting + → exit_plan_mode(HITL)→ pending_approval + → approved → 使用写工具实施 +``` + +`exit_plan_mode` 被拒绝时,状态回到 `drafting`,可修订后再次提交。 + +## 功能特性 + +- **会话级计划制品** —— `PlanRecord` 序列化为 JSON 写入 session state,跨 `Runner.run_async` 调用存活 +- **写工具 gate** —— gate 激活时 `before_tool` 拦截 `DEFAULT_WRITE_TOOL_NAMES` 中的工具 +- **子 agent 限制** —— `spawn_subagent` 仅允许只读原型(`Explore`、`Plan`);`dynamic_subagent` 须显式将 `tools` 限制为只读子集 +- **Prompt 注入** —— 无活跃计划时注入 awareness 提示;gate 激活时注入完整 Plan Mode 提示 +- **HITL 工具** —— 三个长运行工具暂停等待人工;续跑在 `before_model` 中处理 +- **UI 自动进入** —— session state 为 `agent_mode=plan` 时自动进入,并从工具 schema 中隐藏 `enter_plan_mode` +- **HITL 幂等** —— 仅处理最新 user turn 的 function response;历史中的旧 rejection 不会回放 +- **并发安全** —— plan 工具在 load → mutate → save 外包 `plan_store_lock`(按 session + branch) + +## 与 Todo / Task / Goal 的关系 + +| 维度 | TodoWriteTool | TaskToolSet | Goal | **Plan Mode** | +| --- | --- | --- | --- | --- | +| 用途 | 步骤清单 | 任务看板 + 依赖 | 会话完成契约 | **设计文档 + 写操作审批** | +| 人工审批 | 无 | 无 | 无(仅 enforcement) | **有(进入 + 退出 HITL)** | +| 拦截写工具 | 无(仅 prompt) | 无 | 无 | **有(代码强制 gate)** | +| state key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | `plan[:branch]` | +| 典型场景 | 批准后跟踪步骤 | 长看板、依赖编排 | 整件事是否做完 | **调研 → 起草 → 批准 → 实施** | + +> Todo / Task 管步骤分解,Goal 管完成契约;**Plan Mode 在人工签字前拦截所有副作用操作。** + +## PlanOptions 构造参数 + +通过 `setup_plan(agent, PlanOptions(...))` 配置: + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `state_key_prefix` | `str` | `"plan"` | state key 前缀;勿使用 `temp:` | +| `plan_prompt` | `str` | `DEFAULT_PLAN_MODE_PROMPT` | gate 激活时注入 | +| `awareness_prompt` | `str` | `DEFAULT_PLAN_AWARENESS_PROMPT` | 无活跃计划时注入 | +| `write_tool_names` | `FrozenSet[str]` | `DEFAULT_WRITE_TOOL_NAMES` | gate 期间拦截的工具名;MCP / 自定义写工具需扩展 | +| `inject_prompt` | `bool` | `True` | gate 激活时是否注入 plan prompt | +| `inject_awareness` | `bool` | `True` | 否则是否注入 awareness prompt | +| `force_enter_plan_state_key` | `Optional[str]` | `"agent_mode"` | UI 自动进入的 session key;`None` 禁用 | +| `force_enter_plan_state_value` | `str` | `"plan"` | 触发自动进入的值 | +| `on_approval` | `Callable` | `None` | `exit_plan_mode` 批准 / 拒绝时的回调 | +| `readonly_subagent_types` | `FrozenSet[str]` | `{"Explore", "Plan"}` | gate 期间允许的 `spawn_subagent` 原型 | +| `readonly_tool_names` | `FrozenSet[str]` | `{"Read", "Grep", "Glob", "webfetch", "websearch"}` | `dynamic_subagent` 允许的 `tools` 子集 | + +> 每个 agent **只调用一次** `setup_plan()`,重复调用会重复挂载 toolset 与 callback。 + +## 工具说明 + +### `enter_plan_mode`(LongRunningFunctionTool) + +请求人工确认后进入 Plan Mode。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `objective` | 是 | 计划目标的简短描述 | + +返回 `{status: "pending_enter", message, objective, plan_id, approval_id, ...}`。续跑格式:`{status: "approved"}` 或 `{status: "rejected", reviewer_note?: "..."}`。 + +当 session 已设 `agent_mode=plan` 或 gate 已激活时,该工具会从 schema 中隐藏。 + +### `update_plan_content` + +追加或替换 Markdown 计划正文。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `content` | 是 | 计划 Markdown | +| `mode` | 否 | `"append"`(默认)或 `"replace"` | + +首次写入会将状态从 `exploring` 推进到 `drafting`。 + +### `exit_plan_mode`(LongRunningFunctionTool) + +提交计划供人工审批。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `summary` | 否 | 给审批人的简短摘要 | + +要求计划正文非空。返回 `{status: "pending_approval", content, preview, ...}`。续跑格式: + +```json +{"status": "approved"} +``` + +或 + +```json +{"status": "rejected", "reviewer_note": "请补充错误处理章节"} +``` + +批准时可选传入 `content` 以应用审批人编辑后的计划。 + +### `ask_user_question`(LongRunningFunctionTool) + +规划期间的定向澄清。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `question` | 是 | 问题文本 | +| `options` | 否 | 建议选项列表 | + +续跑格式:`{status: "answered", question_id: , answer: "<文本>"}`。 + +## 写 Gate 规则 + +当 `PlanRecord.is_gate_active()`(`exploring` / `drafting` / `pending_approval`)时: + +| 工具类型 | 规则 | +| --- | --- | +| Plan 工具(`enter_plan_mode`、`update_plan_content`、`exit_plan_mode`、`ask_user_question`) | 始终允许(含状态校验) | +| `spawn_subagent` | 仅 `Explore`、`Plan` 原型 | +| `dynamic_subagent` | 仅当 `tools` 为 `readonly_tool_names` 的显式子集 | +| `write_tool_names` 中的工具 | 返回 `PLAN_MODE_GATE` 错误 | + +默认拦截:`Write`、`Edit`、`Bash`、`todo_write`、`task_create`、`task_update`、`create_goal`、`update_goal`。 + +## HITL 续跑(宿主集成) + +续跑时提交 user 消息,parts 中包含暂停工具的 `function_response`。`before_model` 调用 `process_hitl_function_response`,将宿主原始 payload 替换为状态机的标准结果(友好 message + 完整 plan),使模型看到与普通 tool 返回一致的结构。 + +**进入 Plan Mode —— 批准:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="enter_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**退出 Plan Mode —— 批准:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**用户问答 —— 回答:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="ask_user_question", + response={"status": "answered", "question_id": 1, "answer": "使用 PostgreSQL"}, + ))], +) +``` + +配合 [AG-UI](./agui.md) 时,长运行工具在续跑前不会发出 `TOOL_CALL_RESULT`;宿主在下一轮 run 中提交 function response。`STATE_SNAPSHOT` 事件暴露 `plan:`,便于实时计划面板。 + +## 进入方式 + +Plan Mode 有两种进入路径,对应不同的触发方与是否经过 `pending_enter` HITL: + +| 维度 | 方式 1:模型调用 `enter_plan_mode` | 方式 2:Session State 信号(UI 驱动) | +| --- | --- | --- | +| 触发方 | 模型判断任务需要规划 | 宿主 / UI 写入 session state | +| 初始状态 | `pending_enter` → HITL 确认 → `exploring` | 直接进入 `exploring` | +| 是否需要进入确认 | 是 | 否 | +| `enter_plan_mode` 工具 | 正常暴露(无 gate 时) | 从 schema 隐藏;若误调用返回 `PLAN_MODE_GATE` | + +### 方式 1:模型调用 `enter_plan_mode`(HITL 确认) + +**流程:** + +1. 模型判断任务需要规划(awareness prompt 引导) +2. 调用 `enter_plan_mode(objective="...")` +3. 状态变为 `pending_enter`,运行暂停,宿主展示确认卡片 +4. 用户批准后续跑,状态进入 `exploring`,写 gate 开启 + +### 方式 2:Session State 信号强制自动进入(UI 驱动) + +**流程:** + +1. 宿主在 session state 写入 `agent_mode = "plan"`(默认值,可通过 `PlanOptions` 自定义 key / value) +2. 下一次 invocation 时,`before_model` 触发 `_ensure_forced_plan` +3. 内部调用 `apply_enter`,**跳过** `pending_enter`,直接进入 `exploring` +4. `PlanToolSet` 从工具 schema 中隐藏 `enter_plan_mode`,避免重复 HITL + +**AG-UI 示例(`examples/plan_mode/static/index.html`):** + +用户在页面切换「Plan 模式」后,每次 run 将 `agent_mode` 写入 AG-UI 请求的 `state` 字段: + +```javascript +// 与 PlanOptions 默认值保持一致 +const FORCE_ENTER_PLAN_STATE_KEY = "agent_mode"; +const FORCE_ENTER_PLAN_STATE_VALUE = "plan"; + +function buildRunState() { + return { + [FORCE_ENTER_PLAN_STATE_KEY]: + agentMode === "plan" ? FORCE_ENTER_PLAN_STATE_VALUE : "agent", + }; +} + +// RunAgentInput.state = buildRunState() +``` + +切换为 Plan 模式后,下一条用户消息会自动进入 `exploring`,页面提示「无需确认 enter_plan_mode」。完整 Demo 见 [examples/plan_mode](../../../examples/plan_mode/)。 + +## 最佳实践 + +- **先挂只读探索能力** —— `FileToolSet` + `SpawnSubAgentTool(EXPLORE_AGENT, PLAN_AGENT)`,再 `setup_plan` +- **扩展 `write_tool_names`** —— agent 若挂载 MCP、Skills 或自定义写工具,将其 `tool.name` 加入 `PlanOptions.write_tool_names` +- **每个 agent 只 `setup_plan` 一次** —— 避免重复 callback +- **HITL 优先用 AG-UI** —— 浏览器卡片比 CLI 模拟 enter / approve / 问答更自然 +- **批准后跟踪步骤** —— 用 `todo_write` 或 `task_create`;Plan Mode 不替代步骤看板 +- **琐碎任务可跳过** —— awareness prompt 允许在说明理由后直接实施;一行修复不必强行走计划 + +## 完整示例 + +| 示例 | 说明 | +| --- | --- | +| [examples/plan_mode](../../../examples/plan_mode/) | orchestrator + `setup_plan` + AG-UI 浏览器 Demo(实时计划面板 + HITL 卡片) | + +安装 AG-UI 扩展:`pip install -e '.[ag-ui]'`,配置 `examples/plan_mode/.env`,运行 `python3 run_agent_with_agui.py`。 diff --git a/docs/mkdocs/zh/tool.md b/docs/mkdocs/zh/tool.md index 54cb3211..b7e2cd1d 100644 --- a/docs/mkdocs/zh/tool.md +++ b/docs/mkdocs/zh/tool.md @@ -33,9 +33,9 @@ Agent 通过以下步骤动态使用工具: | [Streaming Tools(流式工具)](#streaming-tools流式工具) | 实时预览长文本生成 | 使用 StreamingFunctionTool | 代码生成、文档写作 | | [WebFetchTool](#webfetchtool) | 抓取并文本化单个公网 URL | 实例化 WebFetchTool 并加入 tools | 阅读文档页、RFC、changelog、新闻 | | [WebSearchTool](#websearchtool) | 公网搜索引擎检索 | 实例化 WebSearchTool 并加入 tools | 实时资讯、版本发布、事实/定义查询 | -| [TodoWriteTool](#todowritetool-任务清单工具) | 多步任务规划与进度跟踪(整表替换) | 挂载 `TodoWriteTool` | 短清单、无依赖编排、token 不敏感 | -| [Task 工具族](#task-工具族结构化任务看板) | 结构化任务看板(按 id 增量更新 + 依赖) | 挂载 `TaskToolSet` | 长任务板、跨轮跟踪、blockedBy 依赖 | -| [Goal 工具族](#goal-工具族持久会话目标) | 单会话持久目标 + 强制收尾 | `setup_goal(agent)` | 跨轮大目标、宿主设目标、未完成不许收尾 | +| [TodoWriteTool](./tool_todowrite.md) | 多步任务规划与进度跟踪(整表替换) | 挂载 `TodoWriteTool` | 短清单、无依赖编排、token 不敏感 | +| [Task 工具族](./tool_task.md) | 结构化任务看板(按 id 增量更新 + 依赖) | 挂载 `TaskToolSet` | 长任务板、跨轮跟踪、blockedBy 依赖 | +| [Goal 工具族](./goal.md) | 单会话持久目标 + 强制收尾 | `setup_goal(agent)` | 跨轮大目标、宿主设目标、未完成不许收尾 | | [Agent Code Executor](./code_executor.md) | 自动生成并执行代码场景、数据处理场景 | 配置 CodeExecutor | API 自动调用、表格数据处理 | --- @@ -2848,356 +2848,3 @@ DuckDuckGo provider 在命中 instant answer 时,`summary` 字段会包含 DDG - **DuckDuckGo 原始命中**(`ddg_raw_agent`,`dedup_urls=False`):保留 provider 原始召回列表,便于下游处理 - **Google 基线**(`google_agent`,`safe=active`):真实公网搜索 + 服务端单域 `siteSearch` + 客户端多域过滤 + 黑名单 + per-call `lang` 覆盖 - **Google 时效性 Agent**(`google_raw_agent`,`dateRestrict=m6` + `dedup_urls=False`):只保留过去 6 个月索引的结果,适合"最新/what's new"类查询 - ---- - -## TodoWriteTool(任务清单工具) - -`TodoWriteTool` 是框架内置的**结构化任务清单工具**,对齐 Claude Code / DeepAgents 的 `TodoWrite` 语义:模型通过单次 `todo_write` 调用发送**完整、更新后的清单**,工具校验后整体替换上一份清单,并将会话级 state 持久化,从而在多轮 `Runner.run_async` 之间保持计划与进度。 - -适合**步骤较少、无显式依赖边、希望实现简单**的场景。若需要服务端分配 id、按 `taskId` 增量 patch、或 `blockedBy` / `blocks` 依赖编排,请改用下文 [Task 工具族](#task-工具族结构化任务看板)。 - -### 功能特性 - -- **整表替换**:每次调用传入完整 `todos` 数组,新列表**完全覆盖**旧列表(不做智能 merge);唯一合法的清空方式是显式传入 `todos: []` -- **会话级持久化**:清单序列化为 JSON 写入 `tool_context.state["todos[:]"]`(默认前缀 `todos`,**勿用** `temp:`——该前缀会被 `BaseSessionService` 剥离且不持久化) -- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立清单 -- **硬契约校验(代码强制)**:`content` / `activeForm` 非空、至多一个 `in_progress`、`content` 全局唯一;违反时返回 `INVALID_ARGS` / `INVALID_TODOS` -- **Prompt 引导分层**:`DEFAULT_TODO_PROMPT` 经 `process_request` 自动注入 system instruction,描述使用时机与写法;与硬契约分离 -- **响应带回 diff**:成功时返回 `{message, todos, oldTodos}`,便于前端 / CLI 直接渲染当前清单与变更 -- **可选策略钩子**:`nudge_hooks` 只读回调,可在成功响应 `message` 末尾追加策略提示(不得修改清单) -- **全部完成后自动清空**:`clear_on_all_done=True`(默认)时,若传入列表全部为 `completed`,持久化为空列表,避免历史项堆积 - -### TodoWriteTool 参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"todos"` | state key 前缀;勿使用 `temp:` | -| `clear_on_all_done` | `bool` | `True` | 全部为 `completed` 时是否清空持久化列表 | -| `default_nudge` | `str` | 内置文案 | 每次成功响应的基础提示语 | -| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | 只读策略钩子列表 | -| `filters_name` / `filters` | — | `None` | 透传给 `BaseTool` 的 Filter | - -**LLM 调用参数**(`todo_write`): - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `todos` | `array` | 是 | 完整清单;每项含 `content`(祈使句)、`activeForm`(进行时)、`status`(`pending` / `in_progress` / `completed`) | - -**成功响应字段**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `message` | `str` | 基础 nudge + 钩子追加文案 | -| `todos` | `array` | 持久化后的当前清单 | -| `oldTodos` | `array \| null` | 更新前的清单(首次写入为 `null`) | - -### 使用方式 - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TodoWriteTool - -agent = LlmAgent( - name="todo_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="你是规划型助手,多步任务请用 todo_write 维护清单。", - tools=[TodoWriteTool()], -) -``` - -服务端 / 审计读取当前清单: - -```python -from trpc_agent_sdk.tools import get_todos, render_todos - -todos = get_todos(session, branch=agent.name) -print(render_todos(todos)) # ✅ / 🔄 / ⬜ 纯文本 checklist -``` - -### TodoWriteTool 与 Task 工具族对比 - -| 维度 | `TodoWriteTool` | `TaskToolSet` | -| --- | --- | --- | -| 工具数量 | 1(`todo_write`) | 4(`task_create` / `task_update` / `task_get` / `task_list`) | -| 更新方式 | 整表替换 | 按 `taskId` 增量 patch | -| 单项标识 | `content`(唯一键) | `id`(服务端分配) | -| 依赖编排 | 无 | `blockedBy` / `blocks`,完成上游自动 unblock | -| state key | `todos[:branch]` | `tasks[:branch]` | -| 并行 tool 调用 | 整表覆盖,天然 last-write-wins | 内置 `task_store_lock` 串行化 RMW | - -> **建议二选一挂载**;同时挂载易让模型混用两套语义。 - -### TodoWriteTool 完整示例 - -见 [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py):同一 session 内多轮「规划 → 逐项完成」,每轮用 `get_todos` 读回持久化清单。 - ---- - -## Task 工具族(结构化任务看板) - -`TaskToolSet` 暴露四个工具——`task_create`、`task_update`、`task_get`、`task_list`——对齐 Claude Code v2.1.142+ 的结构化 Task 能力。与 `TodoWriteTool` 的整表替换不同,Task 工具族采用**按服务端分配的 `id` 增量更新**:创建时返回 id,后续用 `task_update` 局部修改状态、字段或依赖边。 - -整个看板序列化为**单个 JSON blob** 写入 `tool_context.state["tasks[:]"]`,跨轮存活;`highwatermark` 记录曾分配的最高 id,软删除(`status: deleted`)后**不会复用 id**。 - -### 功能特性 - -- **增量更新**:`task_create` 分配 id;`task_update` 按 `taskId` patch,无需重传整板 -- **依赖编排**:`addBlockedBy` / `removeBlockedBy`(及 `addBlocks` / `removeBlocks`)维护双向边;上游 `completed` 时自动从下游 `blockedBy` 移除并返回 `unblocked` -- **Token 优化**:`task_list` 只返回摘要(省略 `description`);完整详情用 `task_get` -- **硬契约校验**:`subject` 非空、状态合法、依赖存在、**无环**(`detect_cycle`)、默认**至多一个 `in_progress`**(`enforce_single_in_progress`,可关) -- **并发安全**:`_TaskToolBase` 在 load → mutate → save 外包 `task_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` 下同批并行调用 -- **Prompt 自动注入**:`DEFAULT_TASK_PROMPT` 多工具挂载时只注入一次 - -### TaskToolSet 构造参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"tasks"` | state key 前缀;勿使用 `temp:` | -| `enforce_single_in_progress` | `bool` | `True` | 设置某任务 `in_progress` 时,若已有其他 `in_progress` 则拒绝 | -| `inject_prompt` | `bool` | `True` | 是否向 system instruction 注入 `DEFAULT_TASK_PROMPT` | - -### 四个工具的 LLM 参数概要 - -**`task_create`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `subject` | 是 | 短标题(祈使句) | -| `description` | 否 | 自由文本详情 | -| `activeForm` | 否 | 进行时文案 | -| `metadata` | 否 | 扩展键值 | - -返回 `{task: {id, subject}, message}`。 - -**`task_update`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `taskId` | 是 | 要更新的任务 id | -| `status` | 否 | `pending` / `in_progress` / `completed` / `deleted` | -| `subject` / `description` / `activeForm` / `owner` / `metadata` | 否 | 标量字段 patch | -| `addBlockedBy` / `removeBlockedBy` | 否 | 上游依赖 id 列表 | -| `addBlocks` / `removeBlocks` | 否 | 下游阻塞 id 列表 | - -返回 `{task, unblocked, message}`;`unblocked` 为因本次完成而解除阻塞的 pending 任务 id 列表。 - -**`task_get`**:`taskId`(必填)→ 含 `description` 的完整记录。 - -**`task_list`**:可选 `includeDeleted`;返回 `{tasks, stats}`,摘要不含 `description`。 - -**常见错误码**:`INVALID_ARGS`、`INVALID_DEPENDENCY`、`INVALID_STATUS`、`NOT_FOUND`。 - -### 使用方式 - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TaskToolSet - -agent = LlmAgent( - name="task_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="多步项目请用 task_create / task_update 维护看板。", - tools=[TaskToolSet()], - # parallel_tool_calls=True 时,同批多个 task 工具由 task_store_lock 保护 store 一致性 -) -``` - -读回持久化看板(REST / 审计 / demo 收尾): - -```python -from trpc_agent_sdk.tools import get_task_store, render_task_list - -store = get_task_store(session, branch=agent.name) -print(render_task_list(store)) -# ✅ #1 已完成 -# 🔄 #2 进行中 -# ⬜ #3 待办 (blocked by: 2) -``` - -### 依赖与解锁示例 - -```text -#1 设计表结构 - ├──→ #2 实现 API ──→ #3 单元测试 - └──→ #4 编写文档 - -#1 completed → unblocked: ['2', '4'] -#2 completed → unblocked: ['3'] -``` - -### Task 工具族最佳实践 - -- **规划与执行分离**:先 `task_create` 建板并 `addBlockedBy`,再逐项 `in_progress` → `completed` -- **不要编造 id**:只使用 `task_create` 返回的 id -- **并行调用**:开启 `parallel_tool_calls=True` 时,同 board 上的并发 `task_create` / `task_update` 由锁串行化;不同 `branch` 仍并行 -- **与 TodoWrite 二选一**:长板 + 依赖用 Task;短清单用 TodoWrite - -### Task 工具族完整示例 - -| 示例 | 说明 | -| --- | --- | -| [examples/task_tools](../../../examples/task_tools/) | 多轮对话:依赖编排、逐项完成、跨轮 `get_task_store` 读回看板 | -| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | 验证 `parallel_tool_calls` 与 `task_store_lock`(Phase 1–2 无需 API Key) | - ---- - -## Goal 工具族(持久会话目标) - -`GoalToolSet` 暴露三个工具——`create_goal`、`get_goal`、`update_goal`——对齐 Claude Code 的 **Session Goal** 能力。与 `TodoWriteTool`(多行待办)和 `TaskToolSet`(多任务看板)不同,Goal 在每个 session branch 上**至多只有一个**持久目标:在目标为 `active` 期间,模型给出「看起来像最终答案」的文本**不算完成**——必须继续执行,或显式调用 `update_goal('complete' | 'blocked')` 收尾。 - -目标序列化为**单个 JSON blob**(`GoalRecord`)写入 `tool_context.state["goal[:]"]`,跨 `Runner.run_async` 调用存活。除三个模型工具外,完整能力还需通过 `setup_goal()` 挂载一对 **enforcement callbacks**(`before_model` / `after_model`),在**同一次 invocation 内**拦截过早的最终回复并自动重试。 - -### 功能特性 - -- **单目标契约**:每个 branch 一个 `GoalRecord`(`objective` + 三态 `active` / `complete` / `blocked`);`complete` / `blocked` 为**不可逆**终态 -- **跨轮持久化**:随 function-response 的 state delta 落库;**勿用** `temp:` 前缀 -- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立目标 -- **强制收尾(enforcement)**:目标 `active` 时,`after_model` 检测「无 tool call、有可见文本、非 partial」的过早 final,抑制该回复并在同 invocation 内 re-run;`before_model` 注入 user-role nudge -- **fail-open 预算**:`max_retries`(默认 3)次拦截后放行最终回复,避免无限循环;计数器存在 invocation 级 `agent_context.metadata`,不持久化 -- **双入口创建**: - - **模型侧**:`create_goal(objective=...)` —— LLM 判断多步任务后自主创建 - - **宿主侧**:`start_goal(session_service, ...)` —— 应用层在首轮前写入 session,模型无需调用 `create_goal` -- **Prompt 引导分层**:`DEFAULT_GUIDANCE` 在目标 active 时经 `before_model` 注入 system instruction(`inject_guidance=True`);硬约束由 store 校验 + callback 共同保证 -- **并发安全**:`_GoalToolBase` 在 load → mutate → save 外包 `goal_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` - -### 与 Todo / Task 的关系 - -| 维度 | `TodoWriteTool` | `TaskToolSet` | Goal 工具族 | -| --- | --- | --- | --- | -| 粒度 | 多行待办清单 | 多任务看板 + 依赖 | **单个**会话目标 | -| 更新方式 | 整表替换 | 按 `taskId` 增量 | `create_goal` / `update_goal` | -| 未完成能否收尾 | Prompt 引导 | Prompt 引导 | **callback 强制拦截** | -| state key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | -| 典型用途 | 步骤可视、短清单 | 长看板、依赖编排 | 整件事是否算做完 | - -> Todo / Task 管「步骤分解」,Goal 管「整体完成契约」。可组合使用,但避免让模型同时混用过多规划工具。 - -### GoalOptions 构造参数 - -通过 `setup_goal(agent, GoalOptions(...))` 配置: - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"goal"` | state key 前缀;勿使用 `temp:` | -| `inject_guidance` | `bool` | `True` | 是否在 `before_model` 向 system instruction 注入 `DEFAULT_GUIDANCE` | -| `guidance` | `str` | `DEFAULT_GUIDANCE` | 注入的长文案(含串行调用 goal 工具等约定) | -| `max_retries` | `int` | `3` | 同 invocation 内拦截过早 final 的预算;耗尽后 fail-open | -| `nudge_template` | `str` | `DEFAULT_NUDGE` | 拦截后以 user-role 追加的提醒模板(支持 `{attempt}` / `{max_retries}` / `{objective}`) | -| `on_retry` | `Callable[[RetryEvent], None]` | `None` | 每次拦截或预算耗尽时的可观测回调 | - -仅挂载模型工具、不要 enforcement 时,可直接 `tools=[GoalToolSet()]`,但不具备「未完成不许收尾」能力。 - -### 三个工具的 LLM 参数概要 - -**`create_goal`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `objective` | 是 | 完成标准——「done」具体指什么 | - -成功返回 `{message, goal}`;若已有 `active` 目标返回 `{error: "INVALID_STATE: ..."}`。 - -**`get_goal`** - -无参数。有目标时返回 `{message, goal}`;无目标时返回 `{message: "No session goal is set."}`。 - -**`update_goal`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `status` | 是 | `complete`(目标已达成)或 `blocked`(同一阻塞条件反复出现、无用户输入无法继续) | - -成功返回 `{message, goal}`;无 active 目标或已是终态时返回 `{error: "INVALID_STATE: ..."}`。 - -**`GoalRecord` 字段**(JSON 使用 camelCase 别名持久化): - -| 字段 | 说明 | -|------|------| -| `id` | 服务端分配的 uuid | -| `objective` | 完成标准文本 | -| `status` | `active` / `complete` / `blocked` | -| `createdAtUnix` / `updatedAtUnix` | 创建 / 最后更新时间(unix 秒) | -| `terminalAtUnix` | 进入终态的时间(可选) | - -### enforcement 工作流程 - -```text -模型输出 final 文本(无 tool call,goal 仍 active) - ↓ -after_model 判定为 premature final - ↓ -抑制该 final(不提交为答案),retry_count += 1 -before_model 注入 nudge,同 invocation 继续 agent loop - ↓ -retry_count >= max_retries → fail-open,on_retry(reason="exhausted") -``` - -拦截条件(`_is_premature_final`):非 partial、无 error、content 含可见文本,且**不含** `function_call` / `function_response`。 - -### 使用方式 - -推荐一行挂载工具 + callbacks: - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal - -def on_retry(event: RetryEvent) -> None: - if event.reason == "blocked": - print(f"拦截过早收尾 (attempt {event.attempt_number}/{event.max_retries})") - -agent = LlmAgent( - name="goal_agent", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="多步工程任务请用 goal 工具跟踪完成状态。", - tools=[...], # 业务工具,如 BashTool / WriteTool -) -setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) -``` - -宿主在首轮前预注入目标(模型不调用 `create_goal`): - -```python -from trpc_agent_sdk.tools.goal_tools import start_goal - -goal = await start_goal( - session_service, - app_name="my_app", - user_id="user_1", - session_id=session_id, - objective="在当前目录创建 notes/ 并写入 summary.txt 与 example.py", - agent_name=agent.name, # 与 LlmAgent.name 一致,用于 branch 隔离 -) -``` - -读回持久化目标(REST / 审计 / demo 收尾): - -```python -from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal - -goal = get_goal_record(session, branch=agent.name) -print(render_goal(goal)) -# ✅ Goal [complete] -# objective: ... -# created: 1782893110 -# terminal: 1782893116 -``` - -### Goal 工具族最佳实践 - -- **用 `setup_goal` 而非只挂 `GoalToolSet`**:只有 callbacks 才能实现「active 期间不许 final」 -- **模型侧 vs 宿主侧**:Slash command、`/goal`、配置驱动任务用 `start_goal()`;让模型自主判断多步任务时用 `create_goal` -- **一次响应只调一个 goal 工具**:`DEFAULT_GUIDANCE` 要求串行语义;不要同轮 `create_goal` + `update_goal` -- **`blocked` 慎用**:仅当同一阻塞条件跨多次尝试仍无法推进、且需要用户输入或外部状态变化时使用;不要因为任务难、慢或不完整就标记 blocked -- **可观测性**:通过 `on_retry` 记录 `⚡ Premature final intercepted` 与预算耗尽,便于调优 prompt 或 `max_retries` -- **与 Todo / Task 分工**:Todo / Task 展示步骤与依赖;Goal 约束「整件事是否已真正完成」 - -### Goal 工具族完整示例 - -| 示例 | 说明 | -| --- | --- | -| [examples/goal_tools](../../../examples/goal_tools/) | Case 1:模型 `create_goal`;Case 2:宿主 `start_goal` 预注入;演示 enforcement 拦截与 `update_goal(complete)` | diff --git a/docs/mkdocs/zh/tool_task.md b/docs/mkdocs/zh/tool_task.md new file mode 100644 index 00000000..aa7826f7 --- /dev/null +++ b/docs/mkdocs/zh/tool_task.md @@ -0,0 +1,108 @@ +## Task 工具族(结构化任务看板) + +`TaskToolSet` 暴露四个工具——`task_create`、`task_update`、`task_get`、`task_list`——对齐 Claude Code v2.1.142+ 的结构化 Task 能力。与 `TodoWriteTool` 的整表替换不同,Task 工具族采用**按服务端分配的 `id` 增量更新**:创建时返回 id,后续用 `task_update` 局部修改状态、字段或依赖边。 + +整个看板序列化为**单个 JSON blob** 写入 `tool_context.state["tasks[:]"]`,跨轮存活;`highwatermark` 记录曾分配的最高 id,软删除(`status: deleted`)后**不会复用 id**。 + +### 功能特性 + +- **增量更新**:`task_create` 分配 id;`task_update` 按 `taskId` patch,无需重传整板 +- **依赖编排**:`addBlockedBy` / `removeBlockedBy`(及 `addBlocks` / `removeBlocks`)维护双向边;上游 `completed` 时自动从下游 `blockedBy` 移除并返回 `unblocked` +- **Token 优化**:`task_list` 只返回摘要(省略 `description`);完整详情用 `task_get` +- **硬契约校验**:`subject` 非空、状态合法、依赖存在、**无环**(`detect_cycle`)、默认**至多一个 `in_progress`**(`enforce_single_in_progress`,可关) +- **并发安全**:`_TaskToolBase` 在 load → mutate → save 外包 `task_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` 下同批并行调用 +- **Prompt 自动注入**:`DEFAULT_TASK_PROMPT` 多工具挂载时只注入一次 + +### TaskToolSet 构造参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"tasks"` | state key 前缀;勿使用 `temp:` | +| `enforce_single_in_progress` | `bool` | `True` | 设置某任务 `in_progress` 时,若已有其他 `in_progress` 则拒绝 | +| `inject_prompt` | `bool` | `True` | 是否向 system instruction 注入 `DEFAULT_TASK_PROMPT` | + +### 四个工具的 LLM 参数概要 + +**`task_create`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `subject` | 是 | 短标题(祈使句) | +| `description` | 否 | 自由文本详情 | +| `activeForm` | 否 | 进行时文案 | +| `metadata` | 否 | 扩展键值 | + +返回 `{task: {id, subject}, message}`。 + +**`task_update`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `taskId` | 是 | 要更新的任务 id | +| `status` | 否 | `pending` / `in_progress` / `completed` / `deleted` | +| `subject` / `description` / `activeForm` / `owner` / `metadata` | 否 | 标量字段 patch | +| `addBlockedBy` / `removeBlockedBy` | 否 | 上游依赖 id 列表 | +| `addBlocks` / `removeBlocks` | 否 | 下游阻塞 id 列表 | + +返回 `{task, unblocked, message}`;`unblocked` 为因本次完成而解除阻塞的 pending 任务 id 列表。 + +**`task_get`**:`taskId`(必填)→ 含 `description` 的完整记录。 + +**`task_list`**:可选 `includeDeleted`;返回 `{tasks, stats}`,摘要不含 `description`。 + +**常见错误码**:`INVALID_ARGS`、`INVALID_DEPENDENCY`、`INVALID_STATUS`、`NOT_FOUND`。 + +### 使用方式 + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TaskToolSet + +agent = LlmAgent( + name="task_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="多步项目请用 task_create / task_update 维护看板。", + tools=[TaskToolSet()], + # parallel_tool_calls=True 时,同批多个 task 工具由 task_store_lock 保护 store 一致性 +) +``` + +读回持久化看板(REST / 审计 / demo 收尾): + +```python +from trpc_agent_sdk.tools import get_task_store, render_task_list + +store = get_task_store(session, branch=agent.name) +print(render_task_list(store)) +# ✅ #1 已完成 +# 🔄 #2 进行中 +# ⬜ #3 待办 (blocked by: 2) +``` + +### 依赖与解锁示例 + +```text +#1 设计表结构 + ├──→ #2 实现 API ──→ #3 单元测试 + └──→ #4 编写文档 + +#1 completed → unblocked: ['2', '4'] +#2 completed → unblocked: ['3'] +``` + +### Task 工具族最佳实践 + +- **规划与执行分离**:先 `task_create` 建板并 `addBlockedBy`,再逐项 `in_progress` → `completed` +- **不要编造 id**:只使用 `task_create` 返回的 id +- **并行调用**:开启 `parallel_tool_calls=True` 时,同 board 上的并发 `task_create` / `task_update` 由锁串行化;不同 `branch` 仍并行 +- **与 TodoWrite 二选一**:长板 + 依赖用 Task;短清单用 TodoWrite + +### Task 工具族完整示例 + +| 示例 | 说明 | +| --- | --- | +| [examples/task_tools](../../../examples/task_tools/) | 多轮对话:依赖编排、逐项完成、跨轮 `get_task_store` 读回看板 | +| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | 验证 `parallel_tool_calls` 与 `task_store_lock`(Phase 1–2 无需 API Key) | + +--- diff --git a/docs/mkdocs/zh/tool_todowrite.md b/docs/mkdocs/zh/tool_todowrite.md new file mode 100644 index 00000000..6799ad02 --- /dev/null +++ b/docs/mkdocs/zh/tool_todowrite.md @@ -0,0 +1,83 @@ +## TodoWriteTool(任务清单工具) + +`TodoWriteTool` 是框架内置的**结构化任务清单工具**,对齐 Claude Code / DeepAgents 的 `TodoWrite` 语义:模型通过单次 `todo_write` 调用发送**完整、更新后的清单**,工具校验后整体替换上一份清单,并将会话级 state 持久化,从而在多轮 `Runner.run_async` 之间保持计划与进度。 + +适合**步骤较少、无显式依赖边、希望实现简单**的场景。若需要服务端分配 id、按 `taskId` 增量 patch、或 `blockedBy` / `blocks` 依赖编排,请改用 [Task 工具族](./tool_task.md)。 + +### 功能特性 + +- **整表替换**:每次调用传入完整 `todos` 数组,新列表**完全覆盖**旧列表(不做智能 merge);唯一合法的清空方式是显式传入 `todos: []` +- **会话级持久化**:清单序列化为 JSON 写入 `tool_context.state["todos[:]"]`(默认前缀 `todos`,**勿用** `temp:`——该前缀会被 `BaseSessionService` 剥离且不持久化) +- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立清单 +- **硬契约校验(代码强制)**:`content` / `activeForm` 非空、至多一个 `in_progress`、`content` 全局唯一;违反时返回 `INVALID_ARGS` / `INVALID_TODOS` +- **Prompt 引导分层**:`DEFAULT_TODO_PROMPT` 经 `process_request` 自动注入 system instruction,描述使用时机与写法;与硬契约分离 +- **响应带回 diff**:成功时返回 `{message, todos, oldTodos}`,便于前端 / CLI 直接渲染当前清单与变更 +- **可选策略钩子**:`nudge_hooks` 只读回调,可在成功响应 `message` 末尾追加策略提示(不得修改清单) +- **全部完成后自动清空**:`clear_on_all_done=True`(默认)时,若传入列表全部为 `completed`,持久化为空列表,避免历史项堆积 + +### TodoWriteTool 参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"todos"` | state key 前缀;勿使用 `temp:` | +| `clear_on_all_done` | `bool` | `True` | 全部为 `completed` 时是否清空持久化列表 | +| `default_nudge` | `str` | 内置文案 | 每次成功响应的基础提示语 | +| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | 只读策略钩子列表 | +| `filters_name` / `filters` | — | `None` | 透传给 `BaseTool` 的 Filter | + +**LLM 调用参数**(`todo_write`): + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `todos` | `array` | 是 | 完整清单;每项含 `content`(祈使句)、`activeForm`(进行时)、`status`(`pending` / `in_progress` / `completed`) | + +**成功响应字段**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `message` | `str` | 基础 nudge + 钩子追加文案 | +| `todos` | `array` | 持久化后的当前清单 | +| `oldTodos` | `array \| null` | 更新前的清单(首次写入为 `null`) | + +### 使用方式 + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TodoWriteTool + +agent = LlmAgent( + name="todo_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="你是规划型助手,多步任务请用 todo_write 维护清单。", + tools=[TodoWriteTool()], +) +``` + +服务端 / 审计读取当前清单: + +```python +from trpc_agent_sdk.tools import get_todos, render_todos + +todos = get_todos(session, branch=agent.name) +print(render_todos(todos)) # ✅ / 🔄 / ⬜ 纯文本 checklist +``` + +### TodoWriteTool 与 Task 工具族对比 + +| 维度 | `TodoWriteTool` | `TaskToolSet` | +| --- | --- | --- | +| 工具数量 | 1(`todo_write`) | 4(`task_create` / `task_update` / `task_get` / `task_list`) | +| 更新方式 | 整表替换 | 按 `taskId` 增量 patch | +| 单项标识 | `content`(唯一键) | `id`(服务端分配) | +| 依赖编排 | 无 | `blockedBy` / `blocks`,完成上游自动 unblock | +| state key | `todos[:branch]` | `tasks[:branch]` | +| 并行 tool 调用 | 整表覆盖,天然 last-write-wins | 内置 `task_store_lock` 串行化 RMW | + +> **建议二选一挂载**;同时挂载易让模型混用两套语义。 + +### TodoWriteTool 完整示例 + +见 [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py):同一 session 内多轮「规划 → 逐项完成」,每轮用 `get_todos` 读回持久化清单。 + +--- diff --git a/examples/plan_mode/.env b/examples/plan_mode/.env new file mode 100644 index 00000000..dc791393 --- /dev/null +++ b/examples/plan_mode/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/plan_mode/README.md b/examples/plan_mode/README.md new file mode 100644 index 00000000..ea0bc33c --- /dev/null +++ b/examples/plan_mode/README.md @@ -0,0 +1,100 @@ +# Plan Mode 示例 + +演示 tRPC-Agent-Python 的 **Plan Mode**:用 `setup_plan()` 给 LLM Agent 装上「先规划、后实施」的工作流,并配合 `SpawnSubAgentTool`(Explore / Plan 两种子 agent 原型)完成代码探索与方案设计。 + +## 它解决什么问题 + +普通 coding agent 容易上来就改文件、边写边改方向。Plan Mode 把流程拆成两段: + +1. **规划阶段** —— 模型只能用只读工具(Read / Grep / Glob)和子 agent(Explore / Plan)调研代码、撰写计划文档;写文件、改代码、执行命令等「副作用」工具被自动 gate,直到计划被人工批准。 +2. **实施阶段** —— 计划通过后,写工具自动解锁,模型按照批准的计划落地实现,并可用 `todo_write` 跟踪进度。 + +规划期间需要人工介入的三个动作——`enter_plan_mode` / `exit_plan_mode` / `ask_user_question`——都是 `LongRunningFunctionTool`,运行会暂停等待人类响应。这种交互用浏览器页面承载比 CLI 更自然,所以本示例同时提供了一个 AG-UI 服务端和一张零依赖的静态页面。 + +## 目录结构 + +``` +plan_mode/ +├── agent/ +│ ├── agent.py # orchestrator agent 定义:FileToolSet + SpawnSubAgentTool + TodoWriteTool + setup_plan +│ ├── prompts.py # system instruction +│ └── __init__.py +├── static/ +│ └── index.html # AG-UI 浏览器 Demo(单文件,无构建依赖) +├── run_agent_with_agui.py # FastAPI + AG-UI 服务端,托管 agent 接口与静态页 +├── .env # 模型配置(TRPC_AGENT_API_KEY / BASE_URL / MODEL_NAME) +└── README.md +``` + +## 架构 + +``` +orchestrator (LlmAgent + setup_plan) +├── FileToolSet # Read/Grep/Glob 始终可用;Write/Edit/Bash 在计划批准后解锁 +├── SpawnSubAgentTool(EXPLORE_AGENT, PLAN_AGENT) # 只读调研与方案设计子 agent +├── TodoWriteTool # 实施阶段用于跟踪进度 +└── PlanToolSet # enter_plan_mode / update_plan_content / exit_plan_mode / ask_user_question +``` + +- 计划文档持久化在**主 agent 的 session** 中(`state["plan"]`)。 +- 被 spawn 出来的子 agent 只返回文本,不直接改动主 agent 的状态。 + +## 前置条件 + +```bash +# 1. 安装 SDK(含 AG-UI 依赖) +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +python3 -m venv .venv +source .venv/bin/activate +pip3 install -e . + +# 2. 配置模型访问 +# 复制并填写 examples/plan_mode/.env: +TRPC_AGENT_API_KEY=<你的 key> +TRPC_AGENT_BASE_URL=<可选,自定义 endpoint> +TRPC_AGENT_MODEL_NAME=<可选,默认 gpt-4.1-mini> +``` + +## 运行 + +```bash +cd examples/plan_mode + +# 完整 agent + 基于 AG-UI 的浏览器页面(推荐) +python3 run_agent_with_agui.py + +# 自定义监听地址 / 端口 +python3 run_agent_with_agui.py --host 0.0.0.0 --port 8080 +``` + +启动后控制台会打印三个地址: + +``` +Plan Mode AG-UI demo: http://127.0.0.1:18090/ +Agent endpoint: http://127.0.0.1:18090/plan_agent +Health check: http://127.0.0.1:18090/health +``` + +> 未设置 `TRPC_AGENT_API_KEY` 时服务仍能启动,但会打印警告,agent 调用会失败。 + +## 浏览器 Demo 说明 + +打开 ——静态页面和 agent 接口(`/plan_agent`)由同一个 FastAPI 应用提供,因此页面可同源调用接口,无需任何 CORS 配置。 + +页面提供: + +- **聊天面板** —— 与 orchestrator 对话。 +- **实时计划面板** —— 根据 AG-UI 每次运行结束发送的 `STATE_SNAPSHOT` 事件更新(解析 `plan:orchestrator` 这个 session state key,见 `trpc_agent_sdk/plan_mode/_helpers.py:state_key`)。 + +三种 HITL 交互的页面表现: + +| 模型动作 | 页面表现 | 用户操作后续跑格式 | +|---|---|---| +| `enter_plan_mode` | 展示「确认进入 Plan Mode」卡片 | 确认后进入 `exploring` 状态,开启写工具 gate | +| `exit_plan_mode` | 展示「通过 / 拒绝计划」卡片 | `{"role":"tool","toolCallId":...,"content":"{\"status\":\"approved\",...}"}` | +| `ask_user_question` | 展示问题与选项卡片 | 同上;问题文本/选项/`question_id` 取自状态快照中的 `askedQuestions` | + +> `exit_plan_mode` 触发时本次运行会在没有 `TOOL_CALL_RESULT` 的情况下结束——这是 AG-UI 表示「长时间运行的工具调用被暂停」的方式。点击按钮续跑的报文格式,与任何宿主应用需满足的 `process_hitl_function_response` 期望格式一致。 + +静态页 `static/index.html` 是一个**无任何依赖的单文件**(无需构建、无需 npm install),可直接在浏览器打开。如需用 Node 驱动同一接口(例如写测试脚本),参考 [examples/agui/client_js](../agui/client_js) 中的 `@ag-ui/client` 写法。 diff --git a/examples/plan_mode/agent/__init__.py b/examples/plan_mode/agent/__init__.py new file mode 100644 index 00000000..17f6fa68 --- /dev/null +++ b/examples/plan_mode/agent/__init__.py @@ -0,0 +1,9 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from .agent import create_plan_agent + +__all__ = ["create_plan_agent"] diff --git a/examples/plan_mode/agent/agent.py b/examples/plan_mode/agent/agent.py new file mode 100644 index 00000000..944bd7da --- /dev/null +++ b/examples/plan_mode/agent/agent.py @@ -0,0 +1,46 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.plan_mode import setup_plan +from trpc_agent_sdk.tools import FileToolSet +from trpc_agent_sdk.tools import SpawnSubAgentTool +from trpc_agent_sdk.tools import TodoWriteTool + +from .prompts import SYSTEM_INSTRUCTION + +load_dotenv() + + +def create_plan_agent() -> LlmAgent: + model = OpenAIModel( + model_name=os.environ.get("TRPC_AGENT_MODEL_NAME", "gpt-4.1-mini"), + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url=os.environ.get("TRPC_AGENT_BASE_URL"), + ) + agent = LlmAgent( + name="orchestrator", + model=model, + instruction=SYSTEM_INSTRUCTION, + tools=[ + # Full file toolset: write/edit/bash are gated by setup_plan() + # while a plan is active and unlock automatically on approval. + FileToolSet(), + SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT]), + # todo_write is gated during plan mode; use after approval to track implementation. + TodoWriteTool(), + ], + ) + setup_plan(agent) + return agent diff --git a/examples/plan_mode/agent/prompts.py b/examples/plan_mode/agent/prompts.py new file mode 100644 index 00000000..5fa22cd1 --- /dev/null +++ b/examples/plan_mode/agent/prompts.py @@ -0,0 +1,8 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +SYSTEM_INSTRUCTION = """\ +You are a coding assistant. Prefer reusing existing code and patterns in the workspace.""" diff --git a/examples/plan_mode/run_agent_with_agui.py b/examples/plan_mode/run_agent_with_agui.py new file mode 100644 index 00000000..dae47d02 --- /dev/null +++ b/examples/plan_mode/run_agent_with_agui.py @@ -0,0 +1,132 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""AG-UI server for the Plan Mode example. + +Exposes the plan_mode orchestrator (see agent/agent.py) over the AG-UI +protocol and serves a small, dependency-free static HTML page +(static/index.html) from the same FastAPI app so it can call the agent +endpoint same-origin (no CORS setup needed). + +The page lets a human watch the plan document update live and +approve / reject / answer questions from the browser instead of +simulating the decision in Python. + +Run: + cd examples/plan_mode + python3 run_agent_with_agui.py + +Then open http://127.0.0.1:18090/ in a browser. + +Prerequisites: + pip install -e '.[ag-ui]' + Set TRPC_AGENT_API_KEY (and optionally TRPC_AGENT_BASE_URL / TRPC_AGENT_MODEL_NAME) + in examples/plan_mode/.env +""" + +from __future__ import annotations + +import argparse +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.server.ag_ui import AgUiAgent +from trpc_agent_sdk.server.ag_ui import AgUiManager +from trpc_agent_sdk.server.ag_ui import AgUiService + +_EXAMPLE_DIR = Path(__file__).resolve().parent +if str(_EXAMPLE_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLE_DIR)) + +load_dotenv(_EXAMPLE_DIR / ".env") + +HOST = "127.0.0.1" +PORT = 18090 +APP_NAME = "plan_mode_agui_demo" +AGENT_URI = "/plan_agent" +STATIC_DIR = _EXAMPLE_DIR / "static" +INDEX_HTML = STATIC_DIR / "index.html" + +agui_manager = AgUiManager() + + +class HealthResponse(BaseModel): + """Response body for GET /health.""" + + status: str = "ok" + app_name: str + agent_uri: str + + +def _create_agui_agent() -> AgUiAgent: + """Lazy factory so each worker process gets its own agent instance.""" + from agent.agent import create_plan_agent + + return AgUiAgent(trpc_agent=create_plan_agent(), app_name=APP_NAME) + + +@asynccontextmanager +async def _lifespan(app: FastAPI): # noqa: ARG001 + if not os.environ.get("TRPC_AGENT_API_KEY"): + logger.warning( + "TRPC_AGENT_API_KEY is not set — copy .env.example values into %s/.env", + _EXAMPLE_DIR, + ) + if not INDEX_HTML.is_file(): + raise FileNotFoundError(f"Demo page not found: {INDEX_HTML}") + logger.info("Plan Mode AG-UI demo starting up.") + yield + logger.info("Plan Mode AG-UI demo shutting down.") + await agui_manager.close() + + +def create_app() -> FastAPI: + """Build the FastAPI app: AG-UI agent endpoint + static demo page.""" + app = FastAPI(title="Plan Mode AG-UI Demo", lifespan=_lifespan) + + @app.get("/", response_class=FileResponse) + async def index() -> FileResponse: + return FileResponse(INDEX_HTML) + + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: + return HealthResponse(app_name=APP_NAME, agent_uri=AGENT_URI) + + service = AgUiService(APP_NAME, app=app) + service.add_agent(AGENT_URI, _create_agui_agent) + agui_manager.register_service(APP_NAME, service) + agui_manager.set_app(app) + return app + + +app = create_app() + + +def serve(host: str = HOST, port: int = PORT) -> None: + """Start the FastAPI + AG-UI server.""" + print(f"Plan Mode AG-UI demo: http://{host}:{port}/") + print(f"Agent endpoint: http://{host}:{port}{AGENT_URI}") + print(f"Health check: http://{host}:{port}/health") + agui_manager.run(host, port) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plan Mode AG-UI browser demo") + parser.add_argument("--host", default=HOST, help=f"bind address (default: {HOST})") + parser.add_argument("--port", type=int, default=PORT, help=f"listen port (default: {PORT})") + return parser.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + serve(host=args.host, port=args.port) diff --git a/examples/plan_mode/static/index.html b/examples/plan_mode/static/index.html new file mode 100644 index 00000000..bc7119b4 --- /dev/null +++ b/examples/plan_mode/static/index.html @@ -0,0 +1,777 @@ + + + + + +Plan Mode · AG-UI Demo + + + + +
+

Plan Mode / AG-UI Demo

+
+ thread: - + +
+
+ +
+
+
对话
+
+
+
+ + +
+ + +
+
+ +
+
计划 & 待办
+
还没有计划,先发一条消息让 agent 进入 Plan Mode 吧。
+
+
+ + + + diff --git a/examples/plan_mode_with_goal_and_task/.env b/examples/plan_mode_with_goal_and_task/.env new file mode 100644 index 00000000..dc791393 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/plan_mode_with_goal_and_task/README.md b/examples/plan_mode_with_goal_and_task/README.md new file mode 100644 index 00000000..78a3794e --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/README.md @@ -0,0 +1,97 @@ +# Plan Mode + Goal + Task 示例 + +演示 **Plan Mode**、**Goal**、**Task** 三种能力的组合使用:先规划并人工审批,再用 Goal 锁定会话完成契约,用 Task 看板跟踪实施步骤。 + +## 它解决什么问题 + +| 能力 | 职责 | +| --- | --- | +| **Plan Mode** | 调研 → 起草计划 → 人工审批 → 解锁写工具 | +| **Goal** | 会话级持久目标;active 期间不允许过早收尾 | +| **Task** | 批准后按 id 增量更新任务看板,支持依赖编排 | + +三者分工:Plan 管「设计文档 + 写操作 gate」,Task 管步骤分解,Goal 管「整件事是否做完」。 + +## 目录结构 + +``` +plan_mode_with_goal_and_task/ +├── agent/ +│ ├── agent.py # orchestrator:FileToolSet + SpawnSubAgentTool + TaskToolSet + setup_plan + setup_goal +│ ├── prompts.py # 组合 workflow 的 system instruction +│ └── __init__.py +├── static/ +│ └── index.html # AG-UI 浏览器 Demo(计划 + 目标 + 任务三栏侧板) +├── run_agent_with_agui.py +├── .env +└── README.md +``` + +## 架构 + +``` +orchestrator (LlmAgent) +├── FileToolSet # Read/Grep/Glob 始终可用;Write/Edit/Bash 计划批准后解锁 +├── SpawnSubAgentTool # Explore / Plan 只读子 agent +├── TaskToolSet # task_create / task_update / task_get / task_list(gate 期间被拦截) +├── PlanToolSet (setup_plan) # enter_plan_mode / update_plan_content / exit_plan_mode / ask_user_question +└── GoalToolSet (setup_goal) # create_goal / get_goal / update_goal + enforcement callbacks +``` + +典型流程: + +1. 用户描述多步需求(或 UI 切换 Plan 模式) +2. 进入 Plan Mode → 只读调研 → 撰写计划 → `exit_plan_mode` 等待审批 +3. 审批通过后 → `create_goal` 设定会话目标 → `task_create` 分解实施步骤 +4. 逐步执行 Write / Bash,`task_update` 更新进度 +5. 全部完成后 `update_goal(complete)` + +> Plan gate 激活期间,`task_create` / `task_update` / `create_goal` / `update_goal` 会被 `PLAN_MODE_GATE` 拦截(见 `DEFAULT_WRITE_TOOL_NAMES`)。 + +## 前置条件 + +```bash +cd trpc-agent-python +python3 -m venv .venv && source .venv/bin/activate +pip3 install -e '.[ag-ui]' + +# 配置 examples/plan_mode_with_goal_and_task/.env +TRPC_AGENT_API_KEY=<你的 key> +TRPC_AGENT_BASE_URL=<可选> +TRPC_AGENT_MODEL_NAME=<可选,默认 gpt-4.1-mini> +``` + +## 运行 + +```bash +cd examples/plan_mode_with_goal_and_task +python3 run_agent_with_agui.py +``` + +启动后打开 (默认端口 **18091**,与 `plan_mode` 示例的 18090 区分)。 + +## 浏览器 Demo 说明 + +侧板实时展示三块 session state(均来自 AG-UI `STATE_SNAPSHOT`): + +| 面板 | state key | 说明 | +| --- | --- | --- | +| 当前计划 | `plan:orchestrator` | 计划状态、目标、正文、澄清问答 | +| 会话目标 | `goal:orchestrator` | Goal 的 objective / status | +| 任务看板 | `tasks:orchestrator` | TaskStore(id、subject、status、blockedBy) | + +HITL 交互与 [plan_mode](../plan_mode/) 相同:`enter_plan_mode` / `exit_plan_mode` / `ask_user_question` 卡片 + UI Plan 模式自动进入。 + +建议试用 prompt: + +``` +帮我参考 QQ 音乐生成一个类似的前端项目 +``` + +切换 **Plan** 模式后发送,可跳过 `enter_plan_mode` 确认,直接进入规划。 + +## 相关文档 + +- [Plan Mode](../../docs/mkdocs/zh/plan.md) +- [Goal 工具](../../docs/mkdocs/zh/goal.md) +- [Task 工具族](../../docs/mkdocs/zh/tool_task.md) diff --git a/examples/plan_mode_with_goal_and_task/agent/__init__.py b/examples/plan_mode_with_goal_and_task/agent/__init__.py new file mode 100644 index 00000000..513ac2da --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/agent/__init__.py @@ -0,0 +1,9 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from .agent import create_plan_goal_task_agent + +__all__ = ["create_plan_goal_task_agent"] diff --git a/examples/plan_mode_with_goal_and_task/agent/agent.py b/examples/plan_mode_with_goal_and_task/agent/agent.py new file mode 100644 index 00000000..29fe0f89 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/agent/agent.py @@ -0,0 +1,46 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.plan_mode import setup_plan +from trpc_agent_sdk.tools import FileToolSet +from trpc_agent_sdk.tools import SpawnSubAgentTool +from trpc_agent_sdk.tools import TaskToolSet +from trpc_agent_sdk.tools.goal_tools import GoalOptions +from trpc_agent_sdk.tools.goal_tools import setup_goal + +from .prompts import SYSTEM_INSTRUCTION + +load_dotenv() + + +def create_plan_goal_task_agent() -> LlmAgent: + model = OpenAIModel( + model_name=os.environ.get("TRPC_AGENT_MODEL_NAME", "gpt-4.1-mini"), + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url=os.environ.get("TRPC_AGENT_BASE_URL"), + ) + agent = LlmAgent( + name="orchestrator", + model=model, + instruction=SYSTEM_INSTRUCTION, + tools=[ + FileToolSet(), + SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT]), + TaskToolSet(), + ], + ) + setup_plan(agent) + setup_goal(agent, GoalOptions(max_retries=3)) + return agent diff --git a/examples/plan_mode_with_goal_and_task/agent/prompts.py b/examples/plan_mode_with_goal_and_task/agent/prompts.py new file mode 100644 index 00000000..2814b2f7 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/agent/prompts.py @@ -0,0 +1,7 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +SYSTEM_INSTRUCTION = """You are a coding assistant. Prefer reusing existing code and patterns in the workspace.""" diff --git a/examples/plan_mode_with_goal_and_task/run_agent_with_agui.py b/examples/plan_mode_with_goal_and_task/run_agent_with_agui.py new file mode 100644 index 00000000..59e18ba6 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/run_agent_with_agui.py @@ -0,0 +1,127 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""AG-UI server for Plan Mode + Goal + Task combined example. + +Exposes the orchestrator (see agent/agent.py) over the AG-UI protocol and +serves a dependency-free static HTML page (static/index.html) from the same +FastAPI app. The page shows plan, session goal, and task board side panels. + +Run: + cd examples/plan_mode_with_goal_and_task + python3 run_agent_with_agui.py + +Then open http://127.0.0.1:18091/ in a browser. + +Prerequisites: + pip install -e '.[ag-ui]' + Set TRPC_AGENT_API_KEY (and optionally TRPC_AGENT_BASE_URL / TRPC_AGENT_MODEL_NAME) + in examples/plan_mode_with_goal_and_task/.env +""" + +from __future__ import annotations + +import argparse +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.server.ag_ui import AgUiAgent +from trpc_agent_sdk.server.ag_ui import AgUiManager +from trpc_agent_sdk.server.ag_ui import AgUiService + +_EXAMPLE_DIR = Path(__file__).resolve().parent +if str(_EXAMPLE_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLE_DIR)) + +load_dotenv(_EXAMPLE_DIR / ".env") + +HOST = "127.0.0.1" +PORT = 18093 +APP_NAME = "plan_mode_goal_task_agui_demo" +AGENT_URI = "/plan_agent" +STATIC_DIR = _EXAMPLE_DIR / "static" +INDEX_HTML = STATIC_DIR / "index.html" + +agui_manager = AgUiManager() + + +class HealthResponse(BaseModel): + """Response body for GET /health.""" + + status: str = "ok" + app_name: str + agent_uri: str + + +def _create_agui_agent() -> AgUiAgent: + """Lazy factory so each worker process gets its own agent instance.""" + from agent.agent import create_plan_goal_task_agent + + return AgUiAgent(trpc_agent=create_plan_goal_task_agent(), app_name=APP_NAME) + + +@asynccontextmanager +async def _lifespan(app: FastAPI): # noqa: ARG001 + if not os.environ.get("TRPC_AGENT_API_KEY"): + logger.warning( + "TRPC_AGENT_API_KEY is not set — copy .env values into %s/.env", + _EXAMPLE_DIR, + ) + if not INDEX_HTML.is_file(): + raise FileNotFoundError(f"Demo page not found: {INDEX_HTML}") + logger.info("Plan + Goal + Task AG-UI demo starting up.") + yield + logger.info("Plan + Goal + Task AG-UI demo shutting down.") + await agui_manager.close() + + +def create_app() -> FastAPI: + """Build the FastAPI app: AG-UI agent endpoint + static demo page.""" + app = FastAPI(title="Plan + Goal + Task AG-UI Demo", lifespan=_lifespan) + + @app.get("/", response_class=FileResponse) + async def index() -> FileResponse: + return FileResponse(INDEX_HTML) + + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: + return HealthResponse(app_name=APP_NAME, agent_uri=AGENT_URI) + + service = AgUiService(APP_NAME, app=app) + service.add_agent(AGENT_URI, _create_agui_agent) + agui_manager.register_service(APP_NAME, service) + agui_manager.set_app(app) + return app + + +app = create_app() + + +def serve(host: str = HOST, port: int = PORT) -> None: + """Start the FastAPI + AG-UI server.""" + print(f"Plan + Goal + Task AG-UI demo: http://{host}:{port}/") + print(f"Agent endpoint: http://{host}:{port}{AGENT_URI}") + print(f"Health check: http://{host}:{port}/health") + agui_manager.run(host, port) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plan + Goal + Task AG-UI browser demo") + parser.add_argument("--host", default=HOST, help=f"bind address (default: {HOST})") + parser.add_argument("--port", type=int, default=PORT, help=f"listen port (default: {PORT})") + return parser.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + serve(host=args.host, port=args.port) diff --git a/examples/plan_mode_with_goal_and_task/static/index.html b/examples/plan_mode_with_goal_and_task/static/index.html new file mode 100644 index 00000000..df3832aa --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/static/index.html @@ -0,0 +1,903 @@ + + + + + +Plan + Goal + Task · AG-UI Demo + + + + +
+

Plan + Goal + Task / AG-UI Demo

+
+ thread: - + +
+
+ +
+
+
对话
+
+
+
+ + +
+ + +
+
+ +
+
计划 · 目标 · 任务
+
还没有状态。先发一条消息让 agent 进入 Plan Mode,审批通过后会创建 Goal 与 Task 看板。
+
+
+ + + + diff --git a/mkdocs.yml b/mkdocs.yml index ae69b587..f6a46830 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,9 @@ nav: - Model: en/model.md - Tools: - Tool: en/tool.md + - TodoWrite Tool: en/tool_todowrite.md + - Task Tools: en/tool_task.md + - Goal: en/goal.md - Code Executor: en/code_executor.md - Skills: en/skill.md - Session & Memory: @@ -60,6 +63,7 @@ nav: - Runtime: - Filter: en/filter.md - Human in the Loop: en/human_in_the_loop.md + - Plan Mode: en/plan.md - Cancellation: en/cancel.md - 中文: - 概览: zh/index.md @@ -75,6 +79,9 @@ nav: - 模型: zh/model.md - 工具: - 工具: zh/tool.md + - TodoWrite 工具: zh/tool_todowrite.md + - Task 工具: zh/tool_task.md + - Goal 工具: zh/goal.md - 代码执行器: zh/code_executor.md - Skills: zh/skill.md - Session 与 Memory: @@ -102,4 +109,5 @@ nav: - 运行时: - Filter: zh/filter.md - Human in the Loop: zh/human_in_the_loop.md + - Plan Mode: zh/plan.md - Cancellation: zh/cancel.md diff --git a/tests/plan_mode/__init__.py b/tests/plan_mode/__init__.py new file mode 100644 index 00000000..898f538c --- /dev/null +++ b/tests/plan_mode/__init__.py @@ -0,0 +1,5 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. diff --git a/tests/plan_mode/test_plan_mode.py b/tests/plan_mode/test_plan_mode.py new file mode 100644 index 00000000..1851e552 --- /dev/null +++ b/tests/plan_mode/test_plan_mode.py @@ -0,0 +1,962 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import time +import uuid + +import pytest + +from trpc_agent_sdk.plan_mode import PlanStatus +from trpc_agent_sdk.plan_mode import decode_plan +from trpc_agent_sdk.plan_mode import encode_plan +from trpc_agent_sdk.plan_mode import get_plan_record +from trpc_agent_sdk.plan_mode import plan_to_task_subjects +from trpc_agent_sdk.plan_mode._lock import reset_locks_for_tests +from trpc_agent_sdk.plan_mode._models import PlanRecord +from trpc_agent_sdk.plan_mode._store import apply_approval_decision +from trpc_agent_sdk.plan_mode._store import apply_enter +from trpc_agent_sdk.plan_mode._store import apply_enter_decision +from trpc_agent_sdk.plan_mode._store import apply_request_enter +from trpc_agent_sdk.plan_mode._store import apply_request_exit +from trpc_agent_sdk.plan_mode._store import apply_update_content +from trpc_agent_sdk.sessions import InMemorySessionService + + +async def _enter_plan_mode_confirmed(ctx, objective: str = "x") -> None: + from trpc_agent_sdk.plan_mode._long_running_tools import make_enter_plan_mode_tool + from trpc_agent_sdk.plan_mode._long_running_tools import process_hitl_function_response + + tool = make_enter_plan_mode_tool() + pending = await tool._run_async_impl(tool_context=ctx, args={"objective": objective}) + assert pending["status"] == "pending_enter" + process_hitl_function_response( + ctx, + name="enter_plan_mode", + response={ + "status": "approved", + "reviewer_note": "ok", + }, + ) + + +class _FakeSession: + + def __init__(self, state: dict | None = None): + self.state = state or {} + + +@pytest.fixture(autouse=True) +def _reset_plan_locks(): + reset_locks_for_tests() + yield + reset_locks_for_tests() + + +def test_encode_decode_roundtrip(): + record = PlanRecord( + id="abc", + status=PlanStatus.DRAFTING, + objective="refactor auth", + content="## Step 1\nDo thing", + started_at_unix=1, + ) + raw = encode_plan(record) + decoded = decode_plan(raw) + assert decoded is not None + assert decoded.objective == "refactor auth" + assert decoded.status == PlanStatus.DRAFTING + + +def test_request_enter_approve_and_reject(): + now = int(time.time()) + record, err, payload = apply_request_enter(None, objective="build feature", request_id="req-enter", now_unix=now) + assert err is None + assert record.status == PlanStatus.PENDING_ENTER + assert payload["status"] == "pending_enter" + + record, err, result = apply_enter_decision( + record, + decision="approved", + reviewer_note="go", + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.EXPLORING + assert result["status"] == "approved" + + record, err, payload = apply_request_enter(None, objective="another", request_id="req-enter-2", now_unix=now) + assert err is None + _, err, result = apply_enter_decision( + record, + decision="rejected", + reviewer_note="not now", + now_unix=now, + ) + assert err is None + assert result["status"] == "rejected" + + +def test_state_machine_enter_update_exit_approve(): + now = int(time.time()) + record, err = apply_enter(None, objective="build feature", now_unix=now) + assert err is None + assert record.status == PlanStatus.EXPLORING + + record, err = apply_update_content( + record, + content="# Plan\n## Step A", + mode="replace", + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.DRAFTING + + record, err, payload = apply_request_exit( + record, + summary="ready", + request_id="req1", + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.PENDING_APPROVAL + assert payload["status"] == "pending_approval" + + record, err, result = apply_approval_decision( + record, + decision="approved", + reviewer_note="lgtm", + edited_content=None, + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.APPROVED + assert result["status"] == "approved" + + +def test_reject_returns_to_drafting(): + now = int(time.time()) + record, _ = apply_enter(None, objective="x", now_unix=now) + record, _ = apply_update_content(record, content="plan body", mode="replace", now_unix=now) + record, _, _ = apply_request_exit(record, summary="", request_id="r", now_unix=now) + record, err, result = apply_approval_decision( + record, + decision="rejected", + reviewer_note="need more detail", + edited_content=None, + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.DRAFTING + assert result["status"] == "rejected" + + +def test_plan_to_task_subjects(): + record = PlanRecord( + id="1", + status=PlanStatus.APPROVED, + objective="o", + content="## Setup\n\n## Implement\n\nplain", + started_at_unix=1, + ) + assert plan_to_task_subjects(record) == ["Setup", "Implement"] + + +@pytest.mark.asyncio +async def test_plan_tools_e2e_offline(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool, decode_plan, encode_plan, state_key + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._long_running_tools import make_exit_plan_mode_tool + from trpc_agent_sdk.plan_mode._long_running_tools import process_hitl_function_response + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s1") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + + await _enter_plan_mode_confirmed(ctx, objective="migrate auth") + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={ + "content": "## Step 1\nDo work", + "mode": "replace" + }, + ) + exit_tool = make_exit_plan_mode_tool() + pending = await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review"}) + assert pending["status"] == "pending_approval" + + process_hitl_function_response( + ctx, + name="exit_plan_mode", + response={ + "status": "approved", + "reviewer_note": "ok" + }, + ) + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.APPROVED + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + + class _WriteTool(BaseTool): + + def __init__(self): + super().__init__(name="Write", description="write") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + # Re-open gate for write-block test + plan.status = PlanStatus.DRAFTING + ctx.state[state_key("plan", agent.name)] = encode_plan(plan) + blocked = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + plan.status = PlanStatus.APPROVED + ctx.state[state_key("plan", agent.name)] = encode_plan(plan) + allowed = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert allowed is None + + +@pytest.mark.asyncio +async def test_edit_tool_is_blocked_by_default_denylist(): + """Regression test: the real file-editing tool is named ``Edit`` (not + ``edit_file``) — the denylist must match the actual registered name or + the plan gate silently lets file edits through.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_edit") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + + class _EditTool(BaseTool): + + def __init__(self): + super().__init__(name="Edit", description="edit") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + blocked = await callbacks.before_tool(ctx, _EditTool(), {}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + +@pytest.mark.asyncio +async def test_spawn_subagent_restricted_to_readonly_archetypes(): + """spawn_subagent must only bypass the gate for read-only archetypes; + other archetypes (e.g. "default") inherit the parent's full — possibly + write-capable — tool surface and must stay gated.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_spawn") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + + class _SpawnTool(BaseTool): + + def __init__(self): + super().__init__(name="spawn_subagent", description="spawn") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + tool = _SpawnTool() + + allowed = await callbacks.before_tool(ctx, tool, {"subagent_type": "Explore"}, {}) + assert allowed is None + + blocked = await callbacks.before_tool(ctx, tool, {"subagent_type": "default"}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + blocked_missing = await callbacks.before_tool(ctx, tool, {}, {}) + assert blocked_missing is not None and "PLAN_MODE_GATE" in blocked_missing["error"] + + +@pytest.mark.asyncio +async def test_dynamic_subagent_requires_explicit_readonly_tools(): + """dynamic_subagent must only bypass the gate when it explicitly narrows + itself to read-only tools; without that restriction it can inherit the + parent's full tool surface and must stay gated.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_dyn") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + + class _DynamicTool(BaseTool): + + def __init__(self): + super().__init__(name="dynamic_subagent", description="dynamic") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + tool = _DynamicTool() + + allowed = await callbacks.before_tool(ctx, tool, {"tools": ["Read", "Grep"]}, {}) + assert allowed is None + + blocked_write = await callbacks.before_tool(ctx, tool, {"tools": ["Read", "Write"]}, {}) + assert blocked_write is not None and "PLAN_MODE_GATE" in blocked_write["error"] + + blocked_unrestricted = await callbacks.before_tool(ctx, tool, {}, {}) + assert blocked_unrestricted is not None and "PLAN_MODE_GATE" in blocked_unrestricted["error"] + + +@pytest.mark.asyncio +async def test_hitl_response_replaced_with_standardized_payload(): + """The model must see the state machine's standardized resume payload, + not the raw (possibly minimal) decision the host application sent.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._long_running_tools import make_exit_plan_mode_tool + from trpc_agent_sdk.types import Content, FunctionResponse, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_hitl") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={ + "content": "## Step 1", + "mode": "replace" + }, + ) + exit_tool = make_exit_plan_mode_tool() + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review"}) + + # Host resumes with a minimal, non-standard payload. + fr = FunctionResponse(name="exit_plan_mode", response={"status": "approved"}) + request = LlmRequest(contents=[Content(role="user", parts=[Part(function_response=fr)])]) + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + await callbacks.before_model(ctx, request) + + resumed_fr = request.contents[0].parts[0].function_response + assert resumed_fr.response["status"] == "approved" + assert "message" in resumed_fr.response + assert "plan" in resumed_fr.response + + +@pytest.mark.asyncio +async def test_lock_registry_released_on_approval(): + """The per-session lock registry must not grow without bound: approving + a plan should drop its entry from ``_locks``.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool + from trpc_agent_sdk.plan_mode._lock import _locks, store_lock_key + from trpc_agent_sdk.plan_mode._long_running_tools import (make_exit_plan_mode_tool, process_hitl_function_response) + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_lock_approve") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={ + "content": "## Step 1", + "mode": "replace" + }, + ) + exit_tool = make_exit_plan_mode_tool() + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review"}) + key = store_lock_key(ctx, prefix="plan", branch="orch") + assert key in _locks + process_hitl_function_response(ctx, name="exit_plan_mode", response={"status": "approved"}) + assert key not in _locks + + +@pytest.mark.asyncio +async def test_before_model_injects_awareness_without_active_plan(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._prompt import DEFAULT_PLAN_AWARENESS_PROMPT + from trpc_agent_sdk.plan_mode._prompt import _PLAN_AWARENESS_MARKER + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_aware") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + request = LlmRequest() + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt=DEFAULT_PLAN_AWARENESS_PROMPT, + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + + await callbacks.before_model(ctx, request) + + instructions = str(request.config.system_instruction) + assert _PLAN_AWARENESS_MARKER in instructions + assert "ACTIVE PLAN PROMPT" not in instructions + + +@pytest.mark.asyncio +async def test_before_model_injects_active_plan_prompt_when_gate_active(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._prompt import DEFAULT_PLAN_AWARENESS_PROMPT + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_active") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx, objective="build feature") + + request = LlmRequest() + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt=DEFAULT_PLAN_AWARENESS_PROMPT, + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + + await callbacks.before_model(ctx, request) + + instructions = str(request.config.system_instruction) + assert "ACTIVE PLAN PROMPT" in instructions + assert DEFAULT_PLAN_AWARENESS_PROMPT not in instructions + + +@pytest.mark.asyncio +async def test_ask_user_question_hitl_applies_answer_and_is_idempotent(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode import decode_plan, state_key + from trpc_agent_sdk.plan_mode._long_running_tools import ( + make_ask_user_question_tool, + process_hitl_function_response, + ) + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_ask") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx, objective="build app") + ask_tool = make_ask_user_question_tool() + pending = await ask_tool._run_async_impl( + tool_context=ctx, + args={"question": "Which framework?", "options": ["React", "Vue"]}, + ) + assert pending["status"] == "pending_question" + assert pending["question_id"] == 1 + + result = process_hitl_function_response( + ctx, + name="ask_user_question", + response={"answer": "React", "question_id": 1}, + ) + assert result is not None + assert result["status"] == "answered" + + plan = decode_plan(ctx.state.get(state_key("plan", "orch"))) + assert plan is not None + assert plan.asked_questions[0].answer == "React" + + # Re-processing the standardized payload must not corrupt state. + assert process_hitl_function_response( + ctx, + name="ask_user_question", + response=result, + ) is None + + # pending_question payloads from the initial LRO pause are ignored on resume. + assert process_hitl_function_response( + ctx, + name="ask_user_question", + response=pending, + ) is None + + +@pytest.mark.asyncio +async def test_reject_revise_reapprove_does_not_replay_old_rejection(): + """Regression: replaying an older exit_plan_mode rejection from conversation + history must not roll a newer pending submission back to drafting.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool, decode_plan, state_key + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._long_running_tools import make_exit_plan_mode_tool + from trpc_agent_sdk.tools._base_tool import BaseTool + from trpc_agent_sdk.types import Content, FunctionResponse, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + class _WriteTool(BaseTool): + + def __init__(self): + super().__init__(name="Write", description="write") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_reapprove") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx, objective="build player") + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={"content": "## v1", "mode": "replace"}, + ) + exit_tool = make_exit_plan_mode_tool() + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review v1"}) + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + + reject_request = LlmRequest(contents=[ + Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "rejected", "reviewer_note": "need more pages"}, + ))], + ), + ]) + await callbacks.before_model(ctx, reject_request) + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.DRAFTING + + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={"content": "## v2 full", "mode": "replace"}, + ) + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review v2"}) + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.PENDING_APPROVAL + + approve_request = LlmRequest(contents=[ + Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "rejected", "reviewer_note": "need more pages"}, + ))], + ), + Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "approved", "reviewer_note": "looks good"}, + ))], + ), + ]) + await callbacks.before_model(ctx, approve_request) + + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.APPROVED + + blocked = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert blocked is None + + +@pytest.mark.asyncio +async def test_force_enter_plan_via_session_state_signal(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode import decode_plan, state_key + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + from trpc_agent_sdk.types import Content, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + class _WriteTool(BaseTool): + + def __init__(self): + super().__init__(name="Write", description="write") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_ui_plan") + session.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + user_content=Content(role="user", parts=[Part(text="Build a dashboard")]), + ) + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt="awareness", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + + request = LlmRequest() + await callbacks.before_model(ctx, request) + + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.EXPLORING + assert plan.objective == "Build a dashboard" + assert "ACTIVE PLAN PROMPT" in str(request.config.system_instruction) + + blocked = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + +@pytest.mark.asyncio +async def test_force_enter_blocks_enter_plan_mode_tool(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._long_running_tools import make_enter_plan_mode_tool + from trpc_agent_sdk.types import Content, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_block_enter") + session.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + user_content=Content(role="user", parts=[Part(text="Build a player")]), + ) + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt="awareness", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + await callbacks.before_model(ctx, LlmRequest()) + + enter_tool = make_enter_plan_mode_tool() + blocked = await callbacks.before_tool(ctx, enter_tool, {"objective": "x"}, {}) + assert blocked is not None + assert "enter_plan_mode" in blocked["error"] + assert "UI" in blocked["error"] or "automatic" in blocked["error"] + + +@pytest.mark.asyncio +async def test_plan_toolset_hides_enter_plan_mode_when_ui_plan_selected(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.plan_mode._plan_toolset import PlanToolSet + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_hide_enter") + session.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + + tool_names = [tool.name for tool in await PlanToolSet().get_tools(ctx)] + assert "enter_plan_mode" not in tool_names + assert "update_plan_content" in tool_names + assert "exit_plan_mode" in tool_names + assert "ask_user_question" in tool_names + + +@pytest.mark.asyncio +async def test_plan_toolset_keeps_enter_plan_mode_in_agent_mode(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._plan_toolset import PlanToolSet + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_show_enter") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + + tool_names = [tool.name for tool in await PlanToolSet().get_tools(ctx)] + assert "enter_plan_mode" in tool_names + diff --git a/tests/server/ag_ui/_core/test_agui_agent.py b/tests/server/ag_ui/_core/test_agui_agent.py index d837cc91..08bb62eb 100644 --- a/tests/server/ag_ui/_core/test_agui_agent.py +++ b/tests/server/ag_ui/_core/test_agui_agent.py @@ -402,6 +402,114 @@ def test_mixed_tools(self, agui_agent): assert "proxy_tool" in result assert len(result) == 2 + @pytest.mark.asyncio + async def test_expands_nested_toolset_long_running_tools(self, agui_agent): + from trpc_agent_sdk.plan_mode import PlanToolSet + + agent = Mock(spec=BaseAgent) + agent.tools = [PlanToolSet()] + + result = await agui_agent._extract_long_running_tool_names_async(agent) + + assert "exit_plan_mode" in result + assert "ask_user_question" in result + + +# --------------------------------------------------------------------------- +# TestResolveToolNameFromSession +# --------------------------------------------------------------------------- + + +class TestResolveToolNameFromSession: + @pytest.mark.asyncio + async def test_resolves_tool_name_from_session_events(self, agui_agent): + from trpc_agent_sdk import types + from trpc_agent_sdk.events import Event + + session = await agui_agent._session_manager._session_service.create_session( + app_name="test_app", + user_id="test_user", + session_id="thread-1", + ) + call_event = Event( + invocation_id="inv-1", + author="orch", + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + id="call_abc", + name="ask_user_question", + args={"question": "Pick one"}, + )) + ], + ), + ) + await agui_agent._session_manager._session_service.append_event(session=session, event=call_event) + + resolved = await agui_agent._resolve_tool_name_from_session( + thread_id="thread-1", + app_name="test_app", + user_id="test_user", + tool_call_id="call_abc", + ) + assert resolved == "ask_user_question" + + @pytest.mark.asyncio + async def test_extract_tool_results_falls_back_to_session(self, agui_agent): + from ag_ui.core import ToolMessage + from trpc_agent_sdk import types + from trpc_agent_sdk.events import Event + + session = await agui_agent._session_manager._session_service.create_session( + app_name="test_app", + user_id="test_user", + session_id="thread-2", + ) + call_event = Event( + invocation_id="inv-2", + author="orch", + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + id="call_xyz", + name="ask_user_question", + args={"question": "Pick one"}, + )) + ], + ), + ) + await agui_agent._session_manager._session_service.append_event(session=session, event=call_event) + + tool_msg = ToolMessage( + id="msg-tool", + role="tool", + tool_call_id="call_xyz", + content='{"answer": "React", "question_id": 1}', + ) + inp = RunAgentInput( + thread_id="thread-2", + run_id="run-1", + state={}, + messages=[tool_msg], + tools=[], + context=[], + forwarded_props={}, + ) + + results = await agui_agent._extract_tool_results( + inp, + thread_id="thread-2", + app_name="test_app", + user_id="test_user", + ) + + assert len(results) == 1 + assert results[0]["tool_name"] == "ask_user_question" + # --------------------------------------------------------------------------- # TestDefaultRunConfig diff --git a/tests/server/ag_ui/_core/test_session_manager.py b/tests/server/ag_ui/_core/test_session_manager.py index b064a2b5..c6cc61e5 100644 --- a/tests/server/ag_ui/_core/test_session_manager.py +++ b/tests/server/ag_ui/_core/test_session_manager.py @@ -186,6 +186,35 @@ async def test_success(self): assert result is True svc.append_event.assert_called_once() + appended_event = svc.append_event.call_args[0][1] + assert appended_event.partial is False + + async def test_persists_session_state_with_in_memory_service(self): + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.sessions import InMemorySessionService + + svc = InMemorySessionService() + try: + await svc.create_session( + app_name="app", + user_id="u1", + session_id="s1", + state={DEFAULT_FORCE_ENTER_PLAN_STATE_KEY: "agent"}, + ) + mgr = SessionManager(session_service=svc, auto_cleanup=False) + result = await mgr.update_session_state( + "s1", + "app", + "u1", + {DEFAULT_FORCE_ENTER_PLAN_STATE_KEY: DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE}, + ) + assert result is True + + stored = await svc.get_session(app_name="app", user_id="u1", session_id="s1") + assert stored.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] == DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + finally: + await svc.close() async def test_session_not_found(self): svc = _make_session_service() diff --git a/trpc_agent_sdk/plan_mode/__init__.py b/trpc_agent_sdk/plan_mode/__init__.py new file mode 100644 index 00000000..00950dbd --- /dev/null +++ b/trpc_agent_sdk/plan_mode/__init__.py @@ -0,0 +1,61 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Plan Mode — session-scoped design + approval gate before implementation. + +Mount with :func:`setup_plan` on a :class:`trpc_agent_sdk.agents.LlmAgent`. +Pair with :class:`trpc_agent_sdk.tools.SpawnSubAgentTool` and built-in +``EXPLORE_AGENT`` / ``PLAN_AGENT`` archetypes for read-only exploration and design. +""" + +from ._controller import ApprovalEvent +from ._long_running_tools import make_enter_plan_mode_tool +from ._helpers import DEFAULT_READONLY_SUBAGENT_TYPES +from ._helpers import DEFAULT_READONLY_TOOL_NAMES +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import DEFAULT_WRITE_TOOL_NAMES +from ._helpers import PLAN_TOOL_NAMES +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import get_plan_record +from ._helpers import plan_to_task_subjects +from ._helpers import render_plan +from ._helpers import state_key +from ._models import PlanQuestion +from ._models import PlanRecord +from ._models import PlanStatus +from ._plan_toolset import PlanToolSet +from ._prompt import DEFAULT_PLAN_AWARENESS_PROMPT +from ._prompt import DEFAULT_PLAN_MODE_PROMPT +from ._setup import OnApproval +from ._setup import PlanOptions +from ._setup import setup_plan +from ._update_plan_content_tool import UpdatePlanContentTool + +__all__ = [ + "ApprovalEvent", + "make_enter_plan_mode_tool", + "OnApproval", + "PlanOptions", + "PlanQuestion", + "PlanRecord", + "PlanStatus", + "PlanToolSet", + "UpdatePlanContentTool", + "DEFAULT_PLAN_AWARENESS_PROMPT", + "DEFAULT_PLAN_MODE_PROMPT", + "DEFAULT_READONLY_SUBAGENT_TYPES", + "DEFAULT_READONLY_TOOL_NAMES", + "DEFAULT_STATE_KEY_PREFIX", + "DEFAULT_WRITE_TOOL_NAMES", + "PLAN_TOOL_NAMES", + "decode_plan", + "encode_plan", + "get_plan_record", + "plan_to_task_subjects", + "render_plan", + "setup_plan", + "state_key", +] diff --git a/trpc_agent_sdk/plan_mode/_base.py b/trpc_agent_sdk/plan_mode/_base.py new file mode 100644 index 00000000..a36a0a4b --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_base.py @@ -0,0 +1,70 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Shared base for Plan Mode tools.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.tools._base_tool import BaseTool + +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import state_key +from ._lock import plan_store_lock +from ._models import PlanRecord + + +class _PlanToolBase(BaseTool): + """Branch-scoped plan load / save shared by all plan tools.""" + + def __init__( + self, + *, + name: str, + description: str, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, + filters_name: Optional[list[str]] = None, + filters: Optional[list[BaseFilter]] = None, + ) -> None: + super().__init__( + name=name, + description=description, + filters_name=filters_name, + filters=filters, + ) + self._prefix = state_key_prefix or DEFAULT_STATE_KEY_PREFIX + + def _resolve_branch(self, tool_context: InvocationContext) -> str: + return tool_context.branch or tool_context.agent_name or "" + + def _state_key(self, tool_context: InvocationContext) -> str: + return state_key(self._prefix, self._resolve_branch(tool_context)) + + def _load_plan(self, tool_context: InvocationContext) -> Optional[PlanRecord]: + return decode_plan(tool_context.state.get(self._state_key(tool_context))) + + def _save_plan(self, tool_context: InvocationContext, plan: PlanRecord) -> None: + branch = self._resolve_branch(tool_context) + if branch: + plan.branch = branch + tool_context.state[self._state_key(tool_context)] = encode_plan(plan) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + branch = self._resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=self._prefix, branch=branch): + return await self._run_plan(tool_context=tool_context, args=args) + + @abstractmethod + async def _run_plan(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + """Tool logic under plan_store_lock.""" diff --git a/trpc_agent_sdk/plan_mode/_controller.py b/trpc_agent_sdk/plan_mode/_controller.py new file mode 100644 index 00000000..fd2ab7e4 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_controller.py @@ -0,0 +1,296 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""PlanController — prompt injection, write gate, HITL resume handling.""" + +from __future__ import annotations + +import inspect +import time +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import FrozenSet +from typing import List +from typing import Optional + +from pydantic import BaseModel + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.tools._base_tool import BaseTool +from trpc_agent_sdk.types import FunctionResponse + +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE +from ._helpers import DEFAULT_READONLY_SUBAGENT_TYPES +from ._helpers import DEFAULT_READONLY_TOOL_NAMES +from ._helpers import PLAN_TOOL_NAMES +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import state_key +from ._long_running_tools import process_hitl_function_response +from ._models import PlanRecord +from ._models import PlanStatus +from ._prompt import _PLAN_AWARENESS_MARKER +from ._prompt import _PLAN_PROMPT_MARKER +from ._store import apply_enter + +if TYPE_CHECKING: + pass + + +class ApprovalEvent(BaseModel): + """Observability payload when a plan is approved or rejected.""" + + decision: str + agent_name: str + plan: PlanRecord + + +class _PlanCallbacks: + """Model / tool callbacks for Plan Mode enforcement.""" + + def __init__( + self, + *, + state_key_prefix: str, + plan_prompt: str, + awareness_prompt: str, + write_tool_names: FrozenSet[str], + inject_prompt: bool, + inject_awareness: bool, + on_approval: Optional[Callable[[ApprovalEvent], None]], + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY, + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE, + readonly_subagent_types: FrozenSet[str] = DEFAULT_READONLY_SUBAGENT_TYPES, + readonly_tool_names: FrozenSet[str] = DEFAULT_READONLY_TOOL_NAMES, + ) -> None: + self._prefix = state_key_prefix + self._plan_prompt = plan_prompt + self._awareness_prompt = awareness_prompt + self._write_tool_names = write_tool_names + self._inject_prompt = inject_prompt + self._inject_awareness = inject_awareness + self._force_enter_plan_state_key = force_enter_plan_state_key + self._force_enter_plan_state_value = force_enter_plan_state_value + self._on_approval = on_approval + self._readonly_subagent_types = readonly_subagent_types + self._readonly_tool_names = readonly_tool_names + + def _resolve_branch(self, ctx: InvocationContext) -> str: + return ctx.branch or ctx.agent_name or "" + + def _state_key(self, ctx: InvocationContext) -> str: + return state_key(self._prefix, self._resolve_branch(ctx)) + + def _load_plan(self, ctx: InvocationContext) -> Optional[PlanRecord]: + return decode_plan(ctx.state.get(self._state_key(ctx))) + + def _save_plan(self, ctx: InvocationContext, plan: PlanRecord) -> None: + branch = self._resolve_branch(ctx) + if branch: + plan.branch = branch + ctx.state[self._state_key(ctx)] = encode_plan(plan) + + @staticmethod + def _is_hitl_resume(ctx: InvocationContext) -> bool: + if ctx.user_content is None or not ctx.user_content.parts: + return False + return any(part.function_response is not None for part in ctx.user_content.parts) + + @staticmethod + def _objective_from_context(ctx: InvocationContext) -> str: + if ctx.user_content and ctx.user_content.parts: + texts = [part.text.strip() for part in ctx.user_content.parts if part.text and part.text.strip()] + if texts: + return "\n".join(texts)[:500] + return "Current task" + + def _should_force_enter(self, ctx: InvocationContext) -> bool: + """True when a session-state signal requests auto-enter.""" + if not self._force_enter_plan_state_key: + return False + return ctx.state.get(self._force_enter_plan_state_key) == self._force_enter_plan_state_value + + def _ensure_forced_plan(self, ctx: InvocationContext) -> None: + """Auto-enter Plan Mode when the UI/session signal is active.""" + if not self._should_force_enter(ctx) or self._is_hitl_resume(ctx): + return + existing = self._load_plan(ctx) + if existing is not None and existing.is_gate_active(): + return + record, error = apply_enter( + existing, + objective=self._objective_from_context(ctx), + now_unix=int(time.time()), + ) + if error: + logger.warning("auto-enter plan mode could not create plan: %s", error) + return + self._save_plan(ctx, record) + + @staticmethod + def _latest_user_function_responses(request: LlmRequest) -> List[FunctionResponse]: + """Function responses from the newest user turn only. + + ``before_model`` receives the full conversation, but HITL resume + payloads must be applied at most once and only for the current turn. + Re-playing an older ``exit_plan_mode`` rejection while a newer plan + submission is ``pending_approval`` would roll status back to + ``drafting`` and block implementation after a fresh approval. + """ + for content in reversed(request.contents or []): + if content.role != "user" or not content.parts: + continue + responses = [ + part.function_response for part in content.parts + if part.function_response is not None and isinstance(part.function_response.response, dict) + ] + if responses: + return responses + return [] + + def _process_hitl_in_request(self, ctx: InvocationContext, request: LlmRequest) -> None: + for fr in self._latest_user_function_responses(request): + result = process_hitl_function_response( + ctx, + name=fr.name, + response=fr.response, + state_key_prefix=self._prefix, + ) + if result is not None and not result.get("error"): + # Replace the host-supplied raw decision (e.g. just + # {"status": "approved"}) with the state machine's + # standardized payload (friendly message + full plan + # dump) so the model sees the same response it would + # get from a normal (non-HITL) tool call. + fr.response = result + if fr.name == "exit_plan_mode" and self._on_approval: + plan = self._load_plan(ctx) + if plan is not None: + try: + self._on_approval( + ApprovalEvent( + decision=str(fr.response.get("status", "")), + agent_name=ctx.agent_name, + plan=plan, + )) + except Exception as exc: # pylint: disable=broad-except + logger.warning("plan on_approval callback raised: %s", exc) + + def _existing_instructions(self, request: LlmRequest) -> str: + if request.config and request.config.system_instruction: + return str(request.config.system_instruction) + return "" + + async def before_model(self, ctx: InvocationContext, request: LlmRequest) -> Optional[LlmResponse]: + self._process_hitl_in_request(ctx, request) + self._ensure_forced_plan(ctx) + + plan = self._load_plan(ctx) + existing = self._existing_instructions(request) + + if plan is not None and plan.is_gate_active(): + if self._inject_prompt and _PLAN_PROMPT_MARKER not in existing: + request.append_instructions([self._plan_prompt]) + return None + + if (not self._should_force_enter(ctx) and self._inject_awareness and _PLAN_AWARENESS_MARKER not in existing): + request.append_instructions([self._awareness_prompt]) + return None + + async def before_tool( + self, + ctx: InvocationContext, + tool: BaseTool, + args: dict[str, Any], + tool_ctx: dict, + ) -> Optional[dict]: + self._ensure_forced_plan(ctx) + plan = self._load_plan(ctx) + if plan is None or not plan.is_gate_active(): + return None + + tool_name = getattr(tool, "name", "") or "" + + if tool_name == "enter_plan_mode": + if self._should_force_enter(ctx): + return { + "error": ("PLAN_MODE_GATE: the user selected Plan Mode in the UI; entry is " + "automatic. Do not call enter_plan_mode — begin with spawn_subagent " + '("Explore") or ask_user_question.'), + } + if plan is not None and (plan.is_gate_active() or plan.status == PlanStatus.PENDING_ENTER): + return { + "error": ("PLAN_MODE_GATE: already in Plan Mode " + f"(status={plan.status.value}). Do not call enter_plan_mode again — " + "continue planning with spawn_subagent, update_plan_content, or " + "exit_plan_mode."), + } + + if tool_name in PLAN_TOOL_NAMES: + return None + + if tool_name == "spawn_subagent": + subagent_type = args.get("subagent_type") + if subagent_type in self._readonly_subagent_types: + return None + return { + "error": ("PLAN_MODE_GATE: spawn_subagent is restricted to read-only archetypes " + f"({', '.join(sorted(self._readonly_subagent_types))}) while plan status is " + f"{plan.status.value}. Other archetypes may inherit write-capable tools and " + "are blocked until the plan is approved."), + } + + if tool_name == "dynamic_subagent": + requested = args.get("tools") + if isinstance(requested, list) and requested and all(name in self._readonly_tool_names + for name in requested): + return None + return { + "error": ("PLAN_MODE_GATE: dynamic_subagent is only allowed while plan status is " + f"{plan.status.value} if it explicitly restricts `tools` to a subset of " + f"{sorted(self._readonly_tool_names)}. Without an explicit restriction it " + "may inherit write-capable tools."), + } + + if tool_name in self._write_tool_names: + return { + "error": (f"PLAN_MODE_GATE: tool `{tool_name}` is blocked while plan status is " + f"{plan.status.value}. Finish planning and get approval via exit_plan_mode, " + "or use read-only / spawn_subagent tools."), + } + return None + + +def _chain_callbacks(existing: Any, new: Callable) -> List[Callable]: + if existing is None: + return [new] + if isinstance(existing, list): + return [*existing, new] + return [existing, new] + + +def _chain_tool_callback(existing: Any, new: Callable) -> Callable: + + async def _run_one(cb, ctx, tool, args, tool_ctx): + result = cb(ctx, tool, args, tool_ctx) + if inspect.isawaitable(result): + result = await result + return result + + async def chained(ctx, tool, args, tool_ctx): + if existing is not None: + callbacks = existing if isinstance(existing, list) else [existing] + for cb in callbacks: + result = await _run_one(cb, ctx, tool, args, tool_ctx) + if result is not None: + return result + return await _run_one(new, ctx, tool, args, tool_ctx) + + return chained diff --git a/trpc_agent_sdk/plan_mode/_helpers.py b/trpc_agent_sdk/plan_mode/_helpers.py new file mode 100644 index 00000000..b1f5b6fd --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_helpers.py @@ -0,0 +1,166 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""State-key handling and serialisation for Plan Mode.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.log import logger + +from ._models import PlanRecord +from ._models import PlanStatus + +if TYPE_CHECKING: + from trpc_agent_sdk.context import InvocationContext + +DEFAULT_STATE_KEY_PREFIX = "plan" + +# Session-state key/value pair used by AG-UI (or other hosts) to signal that the +# user selected Plan Mode in the UI. When matched, ``_PlanCallbacks`` auto-enters +# plan mode without waiting for ``enter_plan_mode``. +DEFAULT_FORCE_ENTER_PLAN_STATE_KEY = "agent_mode" +DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE = "plan" + +# Write-tool denylist for Plan Mode gate (``_PlanCallbacks.before_tool``). +# +# Matched by exact ``tool.name`` string — a typo or stale name silently +# skips blocking. Extend at mount time via ``PlanOptions.write_tool_names``. +# +# Default entries map to built-in SDK toolsets: +# file_tools → Write, Edit, Bash +# _todo_tool → todo_write +# task_tools → task_create, task_update +# goal_tools → create_goal, update_goal +# +# Not covered here: spawn_subagent / dynamic_subagent (separate gate rules +# in ``_controller.py``). Skills, OpenClaw, MCP, and custom tools must be +# added explicitly if the agent mounts them. +DEFAULT_WRITE_TOOL_NAMES = frozenset({ + "Write", + "Edit", + "Bash", + "todo_write", + "task_create", + "task_update", + "create_goal", + "update_goal", +}) + +# Sub-agent archetypes considered safe to spawn while the plan gate is +# active (their tool surface is fixed to read-only tools — see +# trpc_agent_sdk.agents.sub_agent._defaults.EXPLORE_AGENT / PLAN_AGENT). +# spawn_subagent calls that request any other subagent_type (e.g. "default", +# which inherits the parent's full — potentially write-capable — tool +# surface) are treated as regular tool calls and subject to the write gate. +DEFAULT_READONLY_SUBAGENT_TYPES = frozenset({"Explore", "Plan"}) + +# Tool names dynamic_subagent is allowed to narrow itself to while the plan +# gate is active. A dynamic_subagent call is only let through the gate if it +# explicitly restricts itself (via its ``tools`` argument) to names from this +# set; otherwise it could inherit the parent's full tool surface and is +# blocked like any other write-capable call. +DEFAULT_READONLY_TOOL_NAMES = frozenset({"Read", "Grep", "Glob", "webfetch", "websearch"}) + +PLAN_TOOL_NAMES = frozenset({ + "enter_plan_mode", + "update_plan_content", + "exit_plan_mode", + "ask_user_question", +}) + + +def should_hide_enter_plan_mode_tool( + invocation_context: Optional["InvocationContext"], + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY, + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE, +) -> bool: + """True when ``enter_plan_mode`` should be omitted from the LLM tool schema. + + Hosts (e.g. AG-UI Plan toggle) set ``agent_mode=plan`` in session state to + auto-enter; exposing ``enter_plan_mode`` would invite redundant HITL calls. + Also hides the tool while a plan gate is already active. + """ + if invocation_context is None: + return False + state = invocation_context.state + if (force_enter_plan_state_key and state.get(force_enter_plan_state_key) == force_enter_plan_state_value): + return True + branch = invocation_context.branch or invocation_context.agent_name or "" + plan = decode_plan(state.get(state_key(state_key_prefix, branch))) + if plan is None: + return False + return plan.is_gate_active() or plan.status == PlanStatus.PENDING_ENTER + + +def state_key(prefix: str, branch: str) -> str: + """Build the state key, appending ``:`` for multi-branch isolation.""" + prefix = prefix or DEFAULT_STATE_KEY_PREFIX + return prefix if not branch else f"{prefix}:{branch}" + + +def decode_plan(raw: Any) -> Optional[PlanRecord]: + """Decode persisted plan JSON; malformed data degrades to ``None``.""" + if not raw: + return None + try: + if isinstance(raw, str): + return PlanRecord.model_validate_json(raw) + if isinstance(raw, dict): + return PlanRecord.model_validate(raw) + except (ValueError, TypeError) as exc: + logger.warning("Plan mode failed to decode persisted plan: %s", exc) + return None + + +def encode_plan(plan: PlanRecord) -> str: + """Serialise a plan to JSON (camelCase aliases).""" + return plan.model_dump_json(by_alias=True) + + +def get_plan_record( + session: Any, + branch: str = "", + prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> Optional[PlanRecord]: + """Read the current plan from a session object with a ``state`` mapping.""" + state = getattr(session, "state", None) or {} + return decode_plan(state.get(state_key(prefix, branch))) + + +def render_plan(plan: Optional[PlanRecord]) -> str: + """Compact ASCII card for logs / CLIs.""" + if plan is None: + return "(no plan)" + lines = [ + f"📋 Plan [{plan.status.value}]", + f" objective: {plan.objective}", + f" revisions: {plan.content_revisions}", + ] + if plan.content: + preview = plan.content.strip().splitlines() + snippet = "\n".join(f" | {line}" for line in preview[:8]) + if len(preview) > 8: + snippet += f"\n | ... ({len(preview) - 8} more lines)" + lines.append(" content:") + lines.append(snippet) + return "\n".join(lines) + + +def plan_to_task_subjects(plan: PlanRecord) -> List[str]: + """Heuristic: Markdown ``##`` headings → task subject lines (helper for post-approval).""" + subjects: List[str] = [] + for line in plan.content.splitlines(): + match = re.match(r"^##\s+(.+)$", line.strip()) + if match: + subjects.append(match.group(1).strip()) + return subjects diff --git a/trpc_agent_sdk/plan_mode/_lock.py b/trpc_agent_sdk/plan_mode/_lock.py new file mode 100644 index 00000000..6112bb92 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_lock.py @@ -0,0 +1,81 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Per-session locks for PlanRecord read-modify-write.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator +from typing import Dict +from typing import Tuple + +from trpc_agent_sdk.context import InvocationContext + +from ._helpers import state_key + +_LockKey = Tuple[str, str, str, str] +_locks: Dict[_LockKey, asyncio.Lock] = {} +_registry_lock = asyncio.Lock() + + +def store_lock_key( + tool_context: InvocationContext, + *, + prefix: str, + branch: str, +) -> _LockKey: + session = tool_context.session + return ( + getattr(session, "app_name", "") or "", + getattr(session, "user_id", "") or "", + getattr(session, "id", "") or "", + state_key(prefix, branch), + ) + + +async def _get_lock(key: _LockKey) -> asyncio.Lock: + async with _registry_lock: + lock = _locks.get(key) + if lock is None: + lock = asyncio.Lock() + _locks[key] = lock + return lock + + +@asynccontextmanager +async def plan_store_lock( + tool_context: InvocationContext, + *, + prefix: str, + branch: str, +) -> AsyncIterator[None]: + """Serialize load / mutate / save for one session plan.""" + lock = await _get_lock(store_lock_key(tool_context, prefix=prefix, branch=branch)) + async with lock: + yield + + +def release_lock( + tool_context: InvocationContext, + *, + prefix: str, + branch: str, +) -> None: + """Drop the registry entry for one session/branch. + + Call once a plan reaches a terminal state (``approved``) + so the process-global ``_locks`` dict doesn't grow without bound for + long-running servers with many sessions. Safe to call even if no entry + exists. Best-effort: a lock actively held by another coroutine keeps + working via its own reference even after being popped here. + """ + _locks.pop(store_lock_key(tool_context, prefix=prefix, branch=branch), None) + + +def reset_locks_for_tests() -> None: + """Clear the lock registry (tests only).""" + _locks.clear() diff --git a/trpc_agent_sdk/plan_mode/_long_running_tools.py b/trpc_agent_sdk/plan_mode/_long_running_tools.py new file mode 100644 index 00000000..ec23500c --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_long_running_tools.py @@ -0,0 +1,254 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Long-running Plan Mode tools (HITL): enter_plan_mode, exit_plan_mode, ask_user_question.""" + +from __future__ import annotations + +import time +import uuid +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.tools._long_running_tool import LongRunningFunctionTool + +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import state_key +from ._lock import plan_store_lock +from ._lock import release_lock +from ._models import PlanStatus +from ._prompt import DEFAULT_ASK_DESCRIPTION +from ._prompt import DEFAULT_ENTER_DESCRIPTION +from ._prompt import DEFAULT_EXIT_DESCRIPTION +from ._store import apply_enter_decision +from ._store import apply_question_answer +from ._store import apply_register_question +from ._store import apply_request_enter +from ._store import apply_request_exit + + +def _resolve_branch(tool_context: InvocationContext) -> str: + return tool_context.branch or tool_context.agent_name or "" + + +def _load(tool_context: InvocationContext, prefix: str): + return decode_plan(tool_context.state.get(state_key(prefix, _resolve_branch(tool_context)))) + + +def _save(tool_context: InvocationContext, prefix: str, record) -> None: + tool_context.state[state_key(prefix, _resolve_branch(tool_context))] = encode_plan(record) + + +def _clear(tool_context: InvocationContext, prefix: str) -> None: + tool_context.state[state_key(prefix, _resolve_branch(tool_context))] = None + + +def make_enter_plan_mode_tool( + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> LongRunningFunctionTool: + """Build enter_plan_mode LongRunningFunctionTool bound to ``state_key_prefix``.""" + + async def enter_plan_mode( + objective: str, + tool_context: InvocationContext = None, # type: ignore[assignment] + ) -> dict[str, Any]: + """Request human confirmation before entering Plan Mode.""" + if not isinstance(objective, str) or not objective.strip(): + return {"error": "INVALID_ARGS: `objective` is required and must be a non-empty string"} + + branch = _resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=state_key_prefix, branch=branch): + request_id = uuid.uuid4().hex + record, error, payload = apply_request_enter( + _load(tool_context, state_key_prefix), + objective=objective.strip(), + request_id=request_id, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + _save(tool_context, state_key_prefix, record) + return payload + + enter_plan_mode.__name__ = "enter_plan_mode" + enter_plan_mode.__doc__ = DEFAULT_ENTER_DESCRIPTION + return LongRunningFunctionTool(enter_plan_mode) + + +def make_exit_plan_mode_tool( + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> LongRunningFunctionTool: + """Build exit_plan_mode LongRunningFunctionTool bound to ``state_key_prefix``.""" + + async def exit_plan_mode( + summary: str = "", + tool_context: InvocationContext = None, # type: ignore[assignment] + ) -> dict[str, Any]: + """Submit the plan for human approval.""" + branch = _resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=state_key_prefix, branch=branch): + request_id = uuid.uuid4().hex + record, error, payload = apply_request_exit( + _load(tool_context, state_key_prefix), + summary=summary or "", + request_id=request_id, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + _save(tool_context, state_key_prefix, record) + return payload + + exit_plan_mode.__name__ = "exit_plan_mode" + exit_plan_mode.__doc__ = DEFAULT_EXIT_DESCRIPTION + return LongRunningFunctionTool(exit_plan_mode) + + +def make_ask_user_question_tool( + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> LongRunningFunctionTool: + """Build ask_user_question LongRunningFunctionTool.""" + + async def ask_user_question( + question: str, + options: Optional[List[str]] = None, + tool_context: InvocationContext = None, # type: ignore[assignment] + ) -> dict[str, Any]: + """Ask the user a structured question during Plan Mode.""" + if not isinstance(question, str) or not question.strip(): + return {"error": "INVALID_ARGS: `question` is required"} + branch = _resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=state_key_prefix, branch=branch): + request_id = uuid.uuid4().hex + record, error, payload = apply_register_question( + _load(tool_context, state_key_prefix), + question=question.strip(), + options=options, + request_id=request_id, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + _save(tool_context, state_key_prefix, record) + return payload + + ask_user_question.__name__ = "ask_user_question" + ask_user_question.__doc__ = DEFAULT_ASK_DESCRIPTION + return LongRunningFunctionTool(ask_user_question) + + +def process_hitl_function_response( + tool_context: InvocationContext, + *, + name: str, + response: dict[str, Any], + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> Optional[dict[str, Any]]: + """Apply human resume payload for enter_plan_mode / exit_plan_mode / ask_user_question. + + Returns the synthetic tool result dict to inject, or None if not handled. + """ + if not isinstance(response, dict): + return None + + branch = _resolve_branch(tool_context) + key = state_key(state_key_prefix, branch) + + if name == "enter_plan_mode": + status = response.get("status") + if status == "pending_enter": + return None + if status not in ("approved", "rejected"): + return None + if isinstance(response.get("plan"), dict): + return None + + existing = _load(tool_context, state_key_prefix) + if existing is not None and existing.status != PlanStatus.PENDING_ENTER: + logger.debug( + "Ignoring enter_plan_mode HITL %s while plan status is %s", + status, + existing.status.value if existing is not None else "none", + ) + return None + + record, error, result = apply_enter_decision( + _load(tool_context, state_key_prefix), + decision=status, + reviewer_note=str(response.get("reviewer_note") or response.get("message") or ""), + now_unix=int(time.time()), + ) + if error: + return {"error": error} + if record is None: + tool_context.state[key] = None + else: + tool_context.state[key] = encode_plan(record) + return result + + if name == "exit_plan_mode": + status = response.get("status") + if status == "pending_approval": + return None + if status not in ("approved", "rejected"): + return None + # Already-normalized tool results from a prior resume must not be + # re-applied when history is scanned again on later turns. + if isinstance(response.get("plan"), dict): + return None + from ._store import apply_approval_decision + + existing = _load(tool_context, state_key_prefix) + if existing is not None and existing.status != PlanStatus.PENDING_APPROVAL: + logger.debug( + "Ignoring exit_plan_mode HITL %s while plan status is %s", + status, + existing.status.value, + ) + return None + + record, error, result = apply_approval_decision( + _load(tool_context, state_key_prefix), + decision=status, + reviewer_note=str(response.get("reviewer_note") or response.get("message") or ""), + edited_content=response.get("content") if isinstance(response.get("content"), str) else None, + now_unix=int(time.time()), + ) + if error: + return {"error": error} + tool_context.state[key] = encode_plan(record) + if record.status == PlanStatus.APPROVED: + release_lock(tool_context, prefix=state_key_prefix, branch=branch) + return result + + if name == "ask_user_question": + status = response.get("status") + if status in ("pending_question", "answered"): + return None + answer = response.get("answer") + if not isinstance(answer, str): + return None + qid = response.get("question_id") + if not isinstance(qid, int): + return None + record, error, result = apply_question_answer( + _load(tool_context, state_key_prefix), + question_id=qid, + answer=answer, + ) + if error: + return {"error": error} + tool_context.state[key] = encode_plan(record) + return result + + return None diff --git a/trpc_agent_sdk/plan_mode/_models.py b/trpc_agent_sdk/plan_mode/_models.py new file mode 100644 index 00000000..d4c2800d --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_models.py @@ -0,0 +1,74 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Data model for Plan Mode (session-scoped design + approval gate).""" + +from __future__ import annotations + +from enum import Enum +from typing import List +from typing import Literal +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + + +class PlanStatus(str, Enum): + """Lifecycle state of a session plan.""" + + PENDING_ENTER = "pending_enter" + EXPLORING = "exploring" + DRAFTING = "drafting" + PENDING_APPROVAL = "pending_approval" + APPROVED = "approved" + + +class PlanQuestion(BaseModel): + """A structured clarification question and optional answer.""" + + id: int + question: str + options: Optional[List[str]] = None + answer: Optional[str] = None + asked_at_unix: int = Field(alias="askedAtUnix") + + model_config = {"populate_by_name": True} + + +class PlanApproval(BaseModel): + """Approval metadata for exit_plan_mode.""" + + request_id: Optional[str] = Field(default=None, alias="requestId") + reviewer_note: Optional[str] = Field(default=None, alias="reviewerNote") + decided_at_unix: Optional[int] = Field(default=None, alias="decidedAtUnix") + + model_config = {"populate_by_name": True} + + +class PlanRecord(BaseModel): + """A single session plan artifact (main agent session state).""" + + id: str + status: PlanStatus + objective: str + content: str = "" + content_revisions: int = Field(default=0, alias="contentRevisions") + asked_questions: List[PlanQuestion] = Field(default_factory=list, alias="askedQuestions") + approval: PlanApproval = Field(default_factory=PlanApproval) + backend: Literal["state", "file"] = "state" + file_path: Optional[str] = Field(default=None, alias="filePath") + started_at_unix: int = Field(alias="startedAtUnix") + branch: Optional[str] = None + + model_config = {"populate_by_name": True} + + def is_gate_active(self) -> bool: + """True while read-only plan gate should block write tools.""" + return self.status in ( + PlanStatus.EXPLORING, + PlanStatus.DRAFTING, + PlanStatus.PENDING_APPROVAL, + ) diff --git a/trpc_agent_sdk/plan_mode/_plan_toolset.py b/trpc_agent_sdk/plan_mode/_plan_toolset.py new file mode 100644 index 00000000..1820fe88 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_plan_toolset.py @@ -0,0 +1,60 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""PlanToolSet — bundles Plan Mode tools.""" + +from __future__ import annotations + +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.abc import ToolSetABC +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._base_tool import BaseTool + +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import should_hide_enter_plan_mode_tool +from ._long_running_tools import make_ask_user_question_tool +from ._long_running_tools import make_enter_plan_mode_tool +from ._long_running_tools import make_exit_plan_mode_tool +from ._update_plan_content_tool import UpdatePlanContentTool + + +class PlanToolSet(ToolSetABC): + """Toolset for session-scoped Plan Mode (enter / draft / approve / exit).""" + + def __init__( + self, + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY, + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE, + name: str = "plan_toolset", + ) -> None: + super().__init__(name=name) + self._prefix = state_key_prefix or DEFAULT_STATE_KEY_PREFIX + self._force_enter_plan_state_key = force_enter_plan_state_key + self._force_enter_plan_state_value = force_enter_plan_state_value + + @override + async def get_tools(self, invocation_context: Optional[InvocationContext] = None) -> List[BaseTool]: + kwargs = {"state_key_prefix": self._prefix} + tools: List[BaseTool] = [ + make_enter_plan_mode_tool(**kwargs), + UpdatePlanContentTool(**kwargs), + make_exit_plan_mode_tool(**kwargs), + make_ask_user_question_tool(**kwargs), + ] + if should_hide_enter_plan_mode_tool( + invocation_context, + state_key_prefix=self._prefix, + force_enter_plan_state_key=self._force_enter_plan_state_key, + force_enter_plan_state_value=self._force_enter_plan_state_value, + ): + tools = [tool for tool in tools if tool.name != "enter_plan_mode"] + return tools diff --git a/trpc_agent_sdk/plan_mode/_prompt.py b/trpc_agent_sdk/plan_mode/_prompt.py new file mode 100644 index 00000000..57b1e55e --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_prompt.py @@ -0,0 +1,367 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Prompt templates for Plan Mode.""" + +DEFAULT_PLAN_AWARENESS_PROMPT = """\ +## Plan Mode (available) + +You have **Plan Mode** tools: `enter_plan_mode`, `update_plan_content`, `exit_plan_mode`, \ +`ask_user_question`. Use them when implementation should be designed and reviewed before \ +writing project files. + +**Prefer `enter_plan_mode`** for non-trivial implementation unless the task is clearly \ +trivial. Call it when any of these apply: +- New feature or meaningful functionality +- Multiple valid approaches or architectural choices +- Multi-file changes (typically more than 2–3 files) +- Unclear requirements — explore before implementing +- User asked for a plan first, or risk is non-trivial + +Examples that **should** enter Plan Mode: "Add authentication", "Build a multi-page frontend \ +app", "Refactor the API layer". See the `enter_plan_mode` tool description for full criteria. + +**Typical flow:** `enter_plan_mode` → Explore (`spawn_subagent` "Explore") → Plan agent → \ +`update_plan_content` → `ask_user_question` if needed → `exit_plan_mode` → implement after \ +approval. + +**You may skip Plan Mode** only for truly trivial work: obvious one-line fixes, typos, or a \ +single tiny change with a fully specified outcome. If you skip it, state a **specific reason** \ +in your reply before implementing — do not skip silently. + +While a plan is **active** (exploring, drafting, or pending approval), write tools are \ +gated — finish planning and get approval via `exit_plan_mode` before editing project files.""" + +DEFAULT_PLAN_MODE_PROMPT = """\ +You are in **Plan Mode**. The user indicated that they do not want you to execute yet — \ +you MUST NOT make any edits (except updating the plan document via `update_plan_content`), \ +run any non-readonly tools (including Write/Edit/Bash, task_create, task_update, \ +todo_write, create_goal, or config/commit changes), or otherwise make any changes to the \ +system. This supersedes any other instructions you have received. + +## Plan document + +The plan lives in the session plan document. Build it \ +incrementally with `update_plan_content` (`append` or `replace` Markdown). **NOTE:** this \ +is the only content you may write during Plan Mode — all other actions must be READ-ONLY. + +## Plan workflow + +### Phase 1: Initial understanding + +Goal: Gain a comprehensive understanding of the user's request by reading code and asking \ +questions. **Critical:** In this phase use only `spawn_subagent` with `subagent_type` \ +**"Explore"** (read-only). + +1. Focus on understanding the request and related code. Actively search for existing \ +functions, utilities, and patterns to reuse — avoid proposing new code when suitable \ +implementations already exist. + +2. **Launch Explore agents IN PARALLEL** when helpful (single message, multiple tool calls) \ +to explore efficiently: + - Use **1 agent** when the task is isolated to known files, the user gave specific \ +paths, or the change is small and targeted. + - Use **multiple agents** when scope is uncertain, several areas of the codebase are \ +involved, or you need existing patterns before planning. + - Quality over quantity — use the **minimum** number of agents necessary (usually 1). + - If using multiple agents: give each a specific search focus (e.g. one for existing \ +implementations, one for related components, one for tests). + +3. To clarify requirements early, use `ask_user_question` — do **not** launch Plan agents \ +in this phase. + +### Phase 2: Design + +Goal: Design an implementation approach. + +Launch `spawn_subagent` with `subagent_type` **"Plan"** based on Phase 1 findings. You \ +may launch multiple Plan agents in parallel for complex tasks. + +**Guidelines:** +- **Default:** Launch at least **1 Plan agent** for most tasks — it validates understanding \ +and surfaces alternatives +- **Skip agents:** Only for truly trivial tasks (typo fixes, single-line changes, simple \ +renames) +- **Multiple agents:** For tasks that benefit from different perspectives — e.g. touches \ +many parts of the codebase, large refactor, many edge cases, or comparing approaches \ +(simplicity vs performance vs maintainability) + +In the sub-agent prompt: +- Provide comprehensive background from Phase 1 (filenames, code paths, findings) +- Describe requirements and constraints +- Request a detailed implementation plan + +Summarize sub-agent output into the plan via `update_plan_content`. + +### Phase 3: Review + +Goal: Review the plan from Phase 2 and ensure alignment with the user's intentions. + +1. Read critical files identified during exploration to deepen your understanding +2. Ensure the plan aligns with the user's original request +3. Use `ask_user_question` to clarify any remaining questions (prefer structured `options` \ +when they exist) + +### Phase 4: Final plan + +Goal: Write your final plan to the session plan document (the only content you may edit). + +Use `update_plan_content` with `mode: "replace"` when producing the polished final version, \ +or `append` for incremental refinements. The plan should: + +- Begin with a **Context** section: why this change is needed, what prompted it, and the \ +intended outcome +- Include only your **recommended** approach, not every alternative considered +- Be concise enough to scan quickly, detailed enough to execute +- List paths of critical files to create or modify +- Reference existing functions and utilities to reuse, with file paths +- Include a **Verification** section: how to test end-to-end (run the app, run tests, \ +manual checks) + +Do NOT paste large code blocks — describe changes; implementation comes after approval. + +### Phase 5: Request approval + +At the end of your turn, once you have asked necessary questions and are happy with the \ +final plan, call `exit_plan_mode` (optionally with a short `summary` for the reviewer) to \ +submit for human approval before any implementation. + +## While pending approval + +If plan status is `pending_approval`, the plan document is **locked**. Do not call \ +`update_plan_content`, do not implement, and do not call `exit_plan_mode` again — wait for \ +the user to approve or reject. If **rejected**, revise the plan with `update_plan_content` \ +then call `exit_plan_mode` again. + +**End-of-turn rule:** Your turn should only end by calling `ask_user_question` OR \ +`exit_plan_mode` — not by stopping after plain text. + +- Use `ask_user_question` **ONLY** to clarify requirements or choose between approaches +- Use `exit_plan_mode` to request plan approval. Do NOT ask about approval in prose or via \ +`ask_user_question` — phrases like "Is this plan okay?", "Should I proceed?", "How does \ +this plan look?", "Any changes before we start?", or similar **must** use `exit_plan_mode` +- Do NOT call `todo_write` or `task_*` during Plan Mode; after approval, break the plan \ +into tasks + +**Note:** At any point you may use `ask_user_question` for clarifications. Do not make \ +large assumptions about user intent. The goal is a well-researched plan with loose ends \ +tied before implementation begins.""" + +DEFAULT_ENTER_DESCRIPTION = """\ +Use this tool proactively when you're about to start a non-trivial implementation task. \ +Getting user sign-off on your approach before writing code prevents wasted effort and \ +ensures alignment. This tool transitions you into Plan Mode where you can explore the \ +codebase read-only and design an implementation approach for user approval via \ +`exit_plan_mode`. + +## When to Use This Tool + +**Prefer using `enter_plan_mode`** for implementation tasks unless they're simple. Use it \ +when ANY of these conditions apply: + +1. **New Feature Implementation**: Adding meaningful new functionality + - Example: "Add a logout button" — where should it go? What should happen on click? + - Example: "Add form validation" — what rules? What error messages? + +2. **Multiple Valid Approaches**: The task can be solved in several different ways + - Example: "Add caching to the API" — Redis, in-memory, file-based, etc. + - Example: "Improve performance" — many optimization strategies possible + +3. **Code Modifications**: Changes that affect existing behavior or structure + - Example: "Update the login flow" — what exactly should change? + - Example: "Refactor this component" — what's the target architecture? + +4. **Architectural Decisions**: The task requires choosing between patterns or technologies + - Example: "Add real-time updates" — WebSockets vs SSE vs polling + - Example: "Implement state management" — Redux vs Context vs custom solution + +5. **Multi-File Changes**: The task will likely touch more than 2–3 files + - Example: "Refactor the authentication system" + - Example: "Add a new API endpoint with tests" + +6. **Unclear Requirements**: You need to explore before understanding the full scope + - Example: "Make the app faster" — need to profile and identify bottlenecks + - Example: "Fix the bug in checkout" — need to investigate root cause + +7. **User Preferences Matter**: The implementation could reasonably go multiple ways + - If you would use `ask_user_question` to clarify the approach, use `enter_plan_mode` \ +instead — Plan Mode lets you explore first, then present options with context + +## When NOT to Use This Tool + +Only skip `enter_plan_mode` for simple tasks: +- Single-line or few-line fixes (typos, obvious bugs, small tweaks) +- Adding a single function with clear requirements +- Tasks where the user has given very specific, detailed instructions +- Pure research/exploration tasks (use `spawn_subagent` with the Explore archetype instead) + +If you skip Plan Mode, state a **specific reason** in your reply before implementing. + +## What Happens + +Calling this tool **requests user confirmation** before Plan Mode starts. The run pauses \ +until the user approves or declines. After approval, write tools are gated until exit approval. \ +Explore read-only with `spawn_subagent` (Explore / Plan archetypes), draft the plan with \ +`update_plan_content`, clarify with `ask_user_question` if needed, then call `exit_plan_mode` \ +for human approval before any implementation. + +## Examples + +### GOOD — Use `enter_plan_mode`: +- "Add user authentication to the app" — session vs JWT, token storage, middleware structure +- "Build a QQ Music–style frontend player" — greenfield multi-file app, stack and layout choices +- "Generate a React + Vite music player UI" — many components, routing, state, mock data +- "Optimize the database queries" — multiple approaches, need to profile first +- "Implement dark mode" — theme system affects many components +- "Add a delete button to the user profile" — placement, confirmation, API, errors, state +- "Update the error handling in the API" — multiple files, user should approve the approach + +### BAD — Don't use `enter_plan_mode`: +- "Fix the typo in the README" — straightforward, no planning needed +- "Add a console.log to debug this function" — simple, obvious implementation +- "What files handle routing?" — research only, not implementation planning + +## Important Notes + +- Implementation approval happens when you call `exit_plan_mode` — the user reviews the \ +plan before you may edit files +- If unsure whether to use this tool, err on the side of planning — alignment upfront \ +beats redoing work +- Users appreciate being consulted before significant changes are made to their codebase""" + +DEFAULT_UPDATE_CONTENT_DESCRIPTION = """\ +Use this tool to write or revise the **plan document** while in Plan Mode. This is the \ +**only** way to record implementation plans during planning — you cannot use Write/Edit on \ +project files until the plan is approved. + +## How This Tool Works + +- Plan text is stored in the session plan document (visible in the Plan Mode UI as you update) +- Pass Markdown in `content`; use `mode` to control how it is applied: + - **`append`** (default): add to the end of the existing plan — use for incremental drafts + - **`replace`**: overwrite the entire plan — use when restructuring or producing a final \ +clean version +- First successful update moves plan status from `exploring` → `drafting` +- This tool does NOT request user approval — call `exit_plan_mode` when the plan is ready \ +for review + +## When to Use This Tool + +Use `update_plan_content` after read-only exploration when you are ready to capture \ +findings and implementation steps: + +1. **After Phase 1 (Explore)** — summarize codebase findings, constraints, and relevant file paths +2. **During Phase 2 (Design)** — record the chosen approach, architecture, and phased steps +3. **During Phase 3 (Review)** — refine the plan after reading critical files or getting \ +answers from `ask_user_question` + +## When NOT to Use This Tool + +- You are not in Plan Mode (`enter_plan_mode` not called) +- Plan status is `pending_approval` or `approved` — content is locked until rejected or \ +a new plan starts +- You want to edit **project source files** — that happens only after `exit_plan_mode` approval +- You only need to ask the user a question — use `ask_user_question` instead +- You are ready for the user to approve — use `exit_plan_mode`, not another content update + +## What to Include + +Keep the plan concise and actionable. Required sections for a final plan: +- **Context** — why this change is needed and the intended outcome +- **Approach** — recommended implementation (file paths, phases, reused utilities) +- **Verification** — how to test end-to-end (run app, tests, manual checks) + +Also include when relevant: +- Assumptions and decisions already made +- Critical files to create or modify (with paths) + +Do NOT paste large code blocks — describe changes; implementation comes after approval. + +## Examples + +### GOOD — Use `update_plan_content`: +- After Explore: append a "Findings" section listing key files and existing patterns +- After Plan sub-agent: replace with a structured implementation plan (tech stack, directory \ +layout, phases) +- After user answers a clarifying question: append the decided approach to the plan + +### BAD — Don't use `update_plan_content`: +- Writing `src/App.tsx` directly — use Write after approval +- Asking "Should I use React or Vue?" — use `ask_user_question` +- Submitting for approval with an empty plan — write content first, then `exit_plan_mode`""" + +DEFAULT_EXIT_DESCRIPTION = """\ +Use this tool when you are in Plan Mode and have finished writing your plan with \ +`update_plan_content` and are ready for user approval. + +## How This Tool Works + +- You should have already written your plan via `update_plan_content` (append or replace \ +Markdown in the session plan document) +- Pass an optional `summary` string — a short note for the reviewer (shown with the approval \ +request); the full plan is read from session state, not from this parameter +- This tool signals that you're done planning and ready for the user to review and approve +- The user will see your plan document when they review it (e.g. in the Plan Mode UI) + +## When to Use This Tool + +IMPORTANT: Only use this tool when the task requires planning the **implementation steps** \ +of work that involves writing code. For research tasks where you're gathering information, \ +searching files, reading files, or trying to understand the codebase — do NOT use this tool. + +## Before Using This Tool + +Ensure your plan is complete and unambiguous: +- If you have unresolved questions about requirements or approach, use `ask_user_question` \ +first (in earlier phases) +- Once your plan is finalized, use THIS tool to request approval + +**Important:** Do NOT use `ask_user_question` to ask "Is this plan okay?" or "Should I \ +proceed?" — that's exactly what THIS tool does. `exit_plan_mode` inherently requests user \ +approval of your plan. + +## After approval or rejection + +- **Approved:** write tools unlock — implement per the plan; use `todo_write` / `task_*` to \ +track work if helpful +- **Rejected:** plan returns to `drafting` — read the reviewer note, revise with \ +`update_plan_content`, then call `exit_plan_mode` again + +## Examples + +1. Initial task: "Search for and understand the implementation of vim mode in the \ +codebase" — Do NOT use `exit_plan_mode` because you are not planning implementation steps. +2. Initial task: "Help me implement yank mode for vim" — Use `exit_plan_mode` after you \ +have finished planning the implementation steps. +3. Initial task: "Add a new feature to handle user authentication" — If unsure about auth \ +method (OAuth, JWT, etc.), use `ask_user_question` first, then use `exit_plan_mode` after \ +clarifying the approach.""" + +DEFAULT_ASK_DESCRIPTION = """\ +Use this tool when you need to ask the user questions during Plan Mode. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take + +Usage notes: +- Ask **one focused question per call** — do not bundle multiple unrelated questions +- Provide an `options` list when structured choices exist; the UI also allows free-text input \ +as a custom answer +- If you recommend a specific option, make that the first option in the list and add \ +"(Recommended)" at the end of the label +- This tool pauses execution until the user answers + +Plan mode note: Use this tool to clarify requirements or choose between approaches **before** \ +finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" \ +— use `exit_plan_mode` for plan approval. + +IMPORTANT: Do not use this tool to request plan sign-off (e.g. "Do you have feedback about \ +the plan?", "Does the plan look good?"). If you need the user to approve the plan, call \ +`exit_plan_mode` instead. Use `ask_user_question` only for open requirements or approach \ +decisions, not for reviewing the finished plan document.""" + +_PLAN_AWARENESS_MARKER = "## Plan Mode (available)" +_PLAN_PROMPT_MARKER = "You are in **Plan Mode**" diff --git a/trpc_agent_sdk/plan_mode/_setup.py b/trpc_agent_sdk/plan_mode/_setup.py new file mode 100644 index 00000000..ed4619cc --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_setup.py @@ -0,0 +1,88 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""``setup_plan`` — mount Plan Mode on an :class:`LlmAgent`.""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import TYPE_CHECKING +from typing import Callable +from typing import FrozenSet +from typing import Optional + +from ._controller import ApprovalEvent +from ._controller import _PlanCallbacks +from ._controller import _chain_callbacks +from ._controller import _chain_tool_callback +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE +from ._helpers import DEFAULT_READONLY_SUBAGENT_TYPES +from ._helpers import DEFAULT_READONLY_TOOL_NAMES +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import DEFAULT_WRITE_TOOL_NAMES +from ._plan_toolset import PlanToolSet +from ._prompt import DEFAULT_PLAN_AWARENESS_PROMPT +from ._prompt import DEFAULT_PLAN_MODE_PROMPT + +if TYPE_CHECKING: + from trpc_agent_sdk.agents import LlmAgent + +OnApproval = Callable[[ApprovalEvent], None] + + +@dataclass +class PlanOptions: + """Configuration for Plan Mode.""" + + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX + plan_prompt: str = DEFAULT_PLAN_MODE_PROMPT + awareness_prompt: str = DEFAULT_PLAN_AWARENESS_PROMPT + write_tool_names: FrozenSet[str] = field(default_factory=lambda: DEFAULT_WRITE_TOOL_NAMES) + inject_prompt: bool = True + inject_awareness: bool = True + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + """Session-state key checked per invocation; when its value equals + ``force_enter_plan_state_value``, auto-enter plan mode without ``enter_plan_mode``. + Set to ``None`` to disable UI-driven auto-enter.""" + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + on_approval: Optional[OnApproval] = None + readonly_subagent_types: FrozenSet[str] = field(default_factory=lambda: DEFAULT_READONLY_SUBAGENT_TYPES) + """spawn_subagent archetypes let through the write gate (must be read-only by tool surface).""" + readonly_tool_names: FrozenSet[str] = field(default_factory=lambda: DEFAULT_READONLY_TOOL_NAMES) + """Tool names dynamic_subagent may self-restrict to in order to pass the write gate.""" + + def toolset(self) -> PlanToolSet: + return PlanToolSet( + state_key_prefix=self.state_key_prefix, + force_enter_plan_state_key=self.force_enter_plan_state_key, + force_enter_plan_state_value=self.force_enter_plan_state_value, + ) + + +def setup_plan(agent: "LlmAgent", opts: Optional[PlanOptions] = None) -> "LlmAgent": + """Mount Plan Mode: tools + prompt injection + write gate + HITL resume. + + Returns the same ``agent`` for chaining. + """ + opts = opts or PlanOptions() + callbacks = _PlanCallbacks( + state_key_prefix=opts.state_key_prefix, + plan_prompt=opts.plan_prompt, + awareness_prompt=opts.awareness_prompt, + write_tool_names=opts.write_tool_names, + inject_prompt=opts.inject_prompt, + inject_awareness=opts.inject_awareness, + force_enter_plan_state_key=opts.force_enter_plan_state_key, + force_enter_plan_state_value=opts.force_enter_plan_state_value, + on_approval=opts.on_approval, + readonly_subagent_types=opts.readonly_subagent_types, + readonly_tool_names=opts.readonly_tool_names, + ) + agent.tools.append(opts.toolset()) + agent.before_model_callback = _chain_callbacks(agent.before_model_callback, callbacks.before_model) + agent.before_tool_callback = _chain_tool_callback(agent.before_tool_callback, callbacks.before_tool) + return agent diff --git a/trpc_agent_sdk/plan_mode/_store.py b/trpc_agent_sdk/plan_mode/_store.py new file mode 100644 index 00000000..ba3c7517 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_store.py @@ -0,0 +1,252 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Plan state-machine transitions (in-memory; caller persists).""" + +from __future__ import annotations + +import uuid +from typing import Optional +from typing import Tuple + +from ._models import PlanQuestion +from ._models import PlanRecord +from ._models import PlanStatus + + +def apply_enter( + existing: Optional[PlanRecord], + *, + objective: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str]]: + """Create a new plan in ``exploring`` state.""" + if existing is not None and existing.is_gate_active(): + return None, (f"a plan is already active (status={existing.status.value}); " + "wait for approval or finish the current plan before entering again") + if existing is not None and existing.status == PlanStatus.PENDING_ENTER: + return None, "enter plan mode request already pending user confirmation" + record = PlanRecord( + id=uuid.uuid4().hex, + status=PlanStatus.EXPLORING, + objective=objective, + started_at_unix=now_unix, + ) + return record, None + + +def apply_request_enter( + existing: Optional[PlanRecord], + *, + objective: str, + request_id: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Stage Plan Mode entry and build HITL payload for human confirmation.""" + if existing is not None and existing.is_gate_active(): + return None, (f"a plan is already active (status={existing.status.value}); " + "wait for approval or finish the current plan before entering again"), None + if existing is not None and existing.status == PlanStatus.PENDING_ENTER: + return None, "enter plan mode request already pending user confirmation", None + + record = PlanRecord( + id=uuid.uuid4().hex, + status=PlanStatus.PENDING_ENTER, + objective=objective, + started_at_unix=now_unix, + ) + record.approval.request_id = request_id + payload = { + "status": "pending_enter", + "message": f"Request to enter Plan Mode: {objective}", + "objective": objective, + "plan_id": record.id, + "approval_id": request_id, + "timestamp": now_unix, + } + return record, None, payload + + +def apply_enter_decision( + existing: Optional[PlanRecord], + *, + decision: str, + reviewer_note: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Apply human approve / reject after enter_plan_mode HITL.""" + if existing is None: + return None, "no plan exists", None + if existing.status != PlanStatus.PENDING_ENTER: + return None, f"plan is not pending enter confirmation (status={existing.status.value})", None + + existing.approval.reviewer_note = reviewer_note or None + existing.approval.decided_at_unix = now_unix + + if decision == "approved": + existing.status = PlanStatus.EXPLORING + return existing, None, { + "status": + "approved", + "message": ("User confirmed Plan Mode. Explore read-only, draft the plan, " + "then call exit_plan_mode for implementation approval."), + "plan": + existing.model_dump(mode="json", by_alias=True), + } + + if decision == "rejected": + note = reviewer_note or "User declined to enter Plan Mode." + return None, None, { + "status": "rejected", + "message": note, + } + + return None, f"unknown decision={decision!r}; expected 'approved' or 'rejected'", None + + +def apply_update_content( + existing: Optional[PlanRecord], + *, + content: str, + mode: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str]]: + """Append or replace plan content; moves exploring → drafting.""" + if existing is None: + return None, "no active plan; call enter_plan_mode first" + if existing.status not in (PlanStatus.EXPLORING, PlanStatus.DRAFTING): + return None, f"cannot edit plan content in status={existing.status.value}" + if mode == "replace": + existing.content = content + elif mode == "append": + if existing.content and not existing.content.endswith("\n"): + existing.content += "\n" + existing.content += content + else: + return None, "mode must be 'append' or 'replace'" + existing.content_revisions += 1 + if existing.status == PlanStatus.EXPLORING: + existing.status = PlanStatus.DRAFTING + return existing, None + + +def apply_request_exit( + existing: Optional[PlanRecord], + *, + summary: str, + request_id: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Move to pending_approval and build HITL payload.""" + if existing is None: + return None, "no active plan; call enter_plan_mode first", None + if existing.status not in (PlanStatus.EXPLORING, PlanStatus.DRAFTING): + return None, f"cannot exit plan mode from status={existing.status.value}", None + if not existing.content.strip(): + return None, "plan content is empty; write the plan before calling exit_plan_mode", None + + existing.status = PlanStatus.PENDING_APPROVAL + existing.approval.request_id = request_id + payload = { + "status": "pending_approval", + "message": summary or "Plan ready for human review.", + "plan_id": existing.id, + "objective": existing.objective, + "content": existing.content, + "preview": existing.content[:2000], + "approval_id": request_id, + "timestamp": now_unix, + } + return existing, None, payload + + +def apply_approval_decision( + existing: Optional[PlanRecord], + *, + decision: str, + reviewer_note: str, + edited_content: Optional[str], + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Apply human approve / reject after exit_plan_mode HITL.""" + if existing is None: + return None, "no plan exists", None + if existing.status != PlanStatus.PENDING_APPROVAL: + return None, f"plan is not pending approval (status={existing.status.value})", None + + existing.approval.reviewer_note = reviewer_note or None + existing.approval.decided_at_unix = now_unix + + if decision == "approved": + if edited_content is not None: + existing.content = edited_content + existing.content_revisions += 1 + existing.status = PlanStatus.APPROVED + return existing, None, { + "status": + "approved", + "message": ("User has approved your plan. You can now start implementation. " + "Consider breaking the plan into tasks with task_create or todo_write."), + "plan": + existing.model_dump(mode="json", by_alias=True), + } + + if decision == "rejected": + existing.status = PlanStatus.DRAFTING + note = reviewer_note or "Plan rejected; revise and call exit_plan_mode again." + return existing, None, { + "status": "rejected", + "message": note, + "plan": existing.model_dump(mode="json", by_alias=True), + } + + return None, f"unknown decision={decision!r}; expected 'approved' or 'rejected'", None + + +def apply_register_question( + existing: Optional[PlanRecord], + *, + question: str, + options: Optional[list], + request_id: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + if existing is None or not existing.is_gate_active(): + return None, "ask_user_question is only available during an active plan", None + qid = len(existing.asked_questions) + 1 + existing.asked_questions.append(PlanQuestion( + id=qid, + question=question, + options=options, + asked_at_unix=now_unix, + )) + payload = { + "status": "pending_question", + "message": question, + "question_id": qid, + "options": options, + "approval_id": request_id, + "timestamp": now_unix, + } + return existing, None, payload + + +def apply_question_answer( + existing: Optional[PlanRecord], + *, + question_id: int, + answer: str, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + if existing is None: + return None, "no active plan", None + for q in existing.asked_questions: + if q.id == question_id and q.answer is None: + q.answer = answer + return existing, None, { + "status": "answered", + "question_id": question_id, + "answer": answer, + } + return None, f"no pending question with id={question_id}", None diff --git a/trpc_agent_sdk/plan_mode/_update_plan_content_tool.py b/trpc_agent_sdk/plan_mode/_update_plan_content_tool.py new file mode 100644 index 00000000..63157cdf --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_update_plan_content_tool.py @@ -0,0 +1,68 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import time +from typing import Any +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ._base import _PlanToolBase +from ._prompt import DEFAULT_UPDATE_CONTENT_DESCRIPTION +from ._store import apply_update_content + + +class UpdatePlanContentTool(_PlanToolBase): + + def __init__(self, *, name: str = "update_plan_content", **kwargs: Any) -> None: + super().__init__(name=name, description=DEFAULT_UPDATE_CONTENT_DESCRIPTION, **kwargs) + + @override + def _get_declaration(self) -> FunctionDeclaration: + return FunctionDeclaration( + name=self.name, + description=DEFAULT_UPDATE_CONTENT_DESCRIPTION, + parameters=Schema( + type=Type.OBJECT, + properties={ + "content": Schema(type=Type.STRING, description="Markdown plan text."), + "mode": Schema( + type=Type.STRING, + description="'append' (default) or 'replace'.", + ), + }, + required=["content"], + ), + ) + + @override + async def _run_plan(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + content = args.get("content") + if not isinstance(content, str): + return {"error": "INVALID_ARGS: `content` must be a string"} + mode = args.get("mode") or "append" + if not isinstance(mode, str): + mode = "append" + + record, error = apply_update_content( + self._load_plan(tool_context), + content=content, + mode=mode, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + + self._save_plan(tool_context, record) + return { + "message": f"Plan content updated ({mode}).", + "plan": record.model_dump(mode="json", by_alias=True), + } diff --git a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py index b9ca48a8..26748674 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py @@ -47,6 +47,7 @@ from trpc_agent_sdk.events import EventTranslatorBase from trpc_agent_sdk.events import LongRunningEvent from trpc_agent_sdk.log import logger +from trpc_agent_sdk.abc import ToolSetABC from trpc_agent_sdk.memory import BaseMemoryService from trpc_agent_sdk.memory import InMemoryMemoryService from trpc_agent_sdk.runners import Runner @@ -374,7 +375,7 @@ def _extract_long_running_tool_names(self, agent: BaseAgent) -> List[str]: List of long-running tool names """ - long_running_tool_names = [] + long_running_tool_names: List[str] = [] if hasattr(agent, "tools") and agent.tools: # Handle both single tool and list of tools @@ -394,6 +395,56 @@ def _extract_long_running_tool_names(self, agent: BaseAgent) -> List[str]: logger.debug("Extracted long-running tool names: %s", long_running_tool_names) return long_running_tool_names + async def _extract_long_running_tool_names_async(self, agent: BaseAgent) -> List[str]: + """Extract long-running tool names, expanding nested toolsets.""" + names = list(self._extract_long_running_tool_names(agent)) + if not hasattr(agent, "tools") or not agent.tools: + return names + + tools = agent.tools if isinstance(agent.tools, (list, tuple)) else [agent.tools] + for tool in tools: + if not isinstance(tool, ToolSetABC): + continue + try: + nested_tools = await tool.get_tools(None) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to expand toolset %s for long-running names: %s", tool, ex) + continue + for nested_tool in nested_tools: + if isinstance(nested_tool, LongRunningFunctionTool): + names.append(nested_tool.name) + return list(dict.fromkeys(names)) + + async def _resolve_tool_name_from_session( + self, + *, + thread_id: str, + app_name: str, + user_id: str, + tool_call_id: str, + ) -> Optional[str]: + """Look up a tool name from persisted session events by tool_call_id.""" + try: + session = await self._session_manager._session_service.get_session( + session_id=thread_id, + app_name=app_name, + user_id=user_id, + ) + if not session or not session.events: + return None + + for event in reversed(session.events): + if not event.content or not event.content.parts: + continue + for part in event.content.parts: + function_call = part.function_call + if function_call and function_call.id == tool_call_id and function_call.name: + return function_call.name + return None + except Exception as ex: # pylint: disable=broad-except + logger.error("Error resolving tool name from session: %s", ex, exc_info=True) + return None + def _create_runner(self, agent: BaseAgent, user_id: str, app_name: str) -> Runner: """Create a new runner instance.""" return Runner( @@ -560,7 +611,14 @@ async def _handle_tool_result_submission(self, thread_id = input.thread_id # Extract tool results that is send by the frontend - tool_results = await self._extract_tool_results(input) + app_name = self.get_app_name(input) + user_id = self.get_user_id(input) + tool_results = await self._extract_tool_results( + input, + thread_id=thread_id, + app_name=app_name, + user_id=user_id, + ) # if the tool results are not sent by the fronted then call the tool function if not tool_results: @@ -601,7 +659,14 @@ async def _handle_tool_result_submission(self, code="TOOL_RESULT_PROCESSING_ERROR", ) - async def _extract_tool_results(self, input: RunAgentInput) -> List[Dict]: + async def _extract_tool_results( + self, + input: RunAgentInput, + *, + thread_id: Optional[str] = None, + app_name: Optional[str] = None, + user_id: Optional[str] = None, + ) -> List[Dict]: """Extract tool messages with their names from input. Only extracts the most recent tool message to avoid accumulation issues @@ -629,6 +694,20 @@ async def _extract_tool_results(self, input: RunAgentInput) -> List[Dict]: if most_recent_tool_message: tool_name = tool_call_map.get(most_recent_tool_message.tool_call_id, "unknown") + if tool_name == "unknown" and thread_id and app_name and user_id: + resolved = await self._resolve_tool_name_from_session( + thread_id=thread_id, + app_name=app_name, + user_id=user_id, + tool_call_id=most_recent_tool_message.tool_call_id, + ) + if resolved: + tool_name = resolved + logger.debug( + "Resolved tool name %s from session for call %s", + tool_name, + most_recent_tool_message.tool_call_id, + ) # Debug: Log the extracted tool message logger.debug("Extracted most recent ToolMessage: role=%s, tool_call_id=%s, content='%s'", @@ -1040,7 +1119,12 @@ async def _run_trpc_in_background(self, # if there is a tool response submission by the user then we need to only pass # the tool response to the trpc runner if self._is_tool_result_submission(input): - tool_results = await self._extract_tool_results(input) + tool_results = await self._extract_tool_results( + input, + thread_id=input.thread_id, + app_name=app_name, + user_id=user_id, + ) parts = [] for tool_msg in tool_results: tool_call_id = tool_msg["message"].tool_call_id @@ -1125,8 +1209,8 @@ async def _run_trpc_in_background(self, new_message = types.Content(parts=[function_response_part], role="user") logger.info("Converted HITL text to function_response for tool_call_id=%s", tool_call_id) - # Extract long-running tool names from the agent - long_running_tool_names = self._extract_long_running_tool_names(agent) + # Extract long-running tool names from the agent (including nested toolsets) + long_running_tool_names = await self._extract_long_running_tool_names_async(agent) # Create event translator event_translator = EventTranslator(long_running_tool_names=long_running_tool_names) diff --git a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py index bec00941..848e7590 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py @@ -207,7 +207,7 @@ async def update_session_state(self, author="system", actions=actions, timestamp=time.time(), - partial=True, + partial=False, ) # Apply changes through TRPC's event system