Skip to content

Commit 6349497

Browse files
authored
refactor(tools): extract supervised batch execution engine (#207)
* feat(core): add batch toolset protocol * refactor(tools): extract execution engine * fix(tools): supervise cancellation timeouts * refactor(soul): consume tool batch summaries * docs(tools): publish execution characterization * docs(tools): document batch execution engine * fix(tools): validate cancellation timeout bounds * fix(tools): supervise result callbacks * docs(tools): plan cancellation state rollback * fix(tools): roll back cancelled batch state * fix(tools): complete cancellation supervision * fix(tools): preserve cancellation through teardown
1 parent 10aedf2 commit 6349497

23 files changed

Lines changed: 9810 additions & 756 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
- **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown.
1919
- **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models.
20+
- **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains.
2021

2122
## 0.58.0 (2026-07-11)
2223

CONTRIBUTING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,19 @@ If you believe a new runtime dependency is genuinely necessary:
4848
automatically so reviewers know to look for the justification.
4949

5050
Dev-only dependencies under `[dependency-groups]` are not subject to this policy.
51+
52+
## Tool-execution characterization
53+
54+
Changes to `soul/tool_execution.py`, execution scheduling, deduplication, the reader/writer gate, or
55+
MCP publication should run the deterministic local characterization harness:
56+
57+
```bash
58+
uv run python scripts/benchmark_toolset.py --scenario all --runs 5 --output before-or-after.json
59+
```
60+
61+
For before/after evidence, use the same machine, Python environment, fixture matrix, warm-up count,
62+
and five-run command at both revisions. Keep the raw JSON and report timing regressions as well as
63+
improvements; do not discard samples or use `--smoke` for final evidence. Confirm every scenario has
64+
zero leaked tasks/processes/sessions, all cancellation and recovery flags are true, registry hashes
65+
are stable, and deterministic decision states are derived by the harness. Treat results as directional
66+
local engineering evidence, not universal product telemetry.

docs/en/customization/agent-architecture.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,9 @@ sequenceDiagram
180180
```mermaid
181181
flowchart LR
182182
Core[pythinker_core.step]
183-
Call[ToolCall]
184-
Toolset[PythinkerToolset.handle]
183+
Calls[Terminal ToolCall batch]
184+
Toolset[PythinkerToolset.handle_batch]
185+
Engine[ToolExecutionEngine]
185186
Parse[Parse JSON arguments]
186187
Pre[PreToolUse hooks]
187188
Execute[tool.call(arguments)]
@@ -193,10 +194,13 @@ flowchart LR
193194
Post[PostToolUse or PostToolUseFailure hooks]
194195
Telemetry[Telemetry]
195196
Result[ToolResult]
197+
Batch[ToolBatchHandle]
198+
Step[StepResult.tool_results]
199+
Soul[PythinkerSoul]
196200
Context[tool_result_to_message -> Context]
197201
Wire[Wire event stream]
198202
199-
Core --> Call --> Toolset --> Parse --> Pre --> Execute
203+
Core --> Calls --> Toolset --> Engine --> Parse --> Pre --> Execute
200204
Execute --> Builtin
201205
Execute --> Plugin
202206
Execute --> MCP
@@ -208,12 +212,26 @@ flowchart LR
208212
Builtin --> Result
209213
Approval --> Result
210214
Result --> Post --> Telemetry
211-
Result --> Core
215+
Result --> Batch --> Step --> Soul
212216
Result --> Wire
213-
Result --> Context
217+
Soul --> Context
214218
```
215219

216-
The toolset is both a registry and an execution boundary. It hides tools from the LLM when needed, validates tool names, parses JSON arguments, triggers hooks, converts exceptions to `ToolRuntimeError`, and returns async `ToolResult` tasks to `pythinker_core.step`. The advertised tool list is filtered by the active execution profile, subagent/root role, plan-mode state, and hard permission profile before each model call; tool-specific execution guards still run even if a hidden tool is somehow called. MCP tools are registered as local wrappers. Wire external tools are sent to the active Wire client as `ToolCallRequest` messages and wait for a client-provided result.
220+
The toolset is both a registry and an execution boundary. For Pythinker Soul, `pythinker_core.step`
221+
passes the complete terminal call batch and immutable `ToolBatchContext` to
222+
`PythinkerToolset.handle_batch`. Its non-exported `ToolExecutionEngine` validates tool names, parses
223+
JSON arguments, applies duplicate/consecutive-call policy, schedules calls through the reader-writer
224+
gate, triggers hooks, converts exceptions to `ToolRuntimeError`, and supervises ordered results and
225+
bounded cancellation through a `ToolBatchHandle`. `StepResult.tool_results()` settles that owned
226+
batch before Soul appends the assistant and tool messages to `Context`. Core's per-call
227+
`Toolset.handle()` path remains only as the compatibility fallback for toolsets that do not
228+
implement `BatchToolset`.
229+
230+
The advertised tool list is filtered by the active execution profile, subagent/root role,
231+
plan-mode state, and hard permission profile before each model call; tool-specific execution guards
232+
still run even if a hidden tool is somehow called. MCP tools are registered as local wrappers. Wire
233+
external tools are sent to the active Wire client as `ToolCallRequest` messages and wait for a
234+
client-provided result.
217235

218236
## Subagent graph
219237

docs/en/customization/architecture.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ The end-to-end flow when a session starts and processes a turn:
6666
4. **Core loop**`src/pythinker_code/soul/pythinkersoul.py:PythinkerSoul.run` handles user
6767
input and slash commands, calls the LLM through `pythinker_core.step`, runs tools, gates
6868
side effects through approvals, injects dynamic reminders, and compacts the context.
69-
5. **Tool execution**`src/pythinker_code/soul/toolset.py:PythinkerToolset` loads built-in
70-
and MCP tools, injects dependencies, executes calls, and returns structured results.
69+
5. **Tool execution**`src/pythinker_code/soul/toolset.py:PythinkerToolset` owns the
70+
built-in/MCP registry, visibility, and dependency injection facade. The private
71+
`src/pythinker_code/soul/tool_execution.py:ToolExecutionEngine` executes terminal batches,
72+
deduplicates calls, orders results, and supervises bounded cancellation.
7173
6. **Wire and UI**`src/pythinker_code/soul/run_soul` connects the soul to
7274
`src/pythinker_code/wire/`; Shell, Print, ACP, Web, and Dashboard frontends consume Wire events.
7375

@@ -99,7 +101,8 @@ canonical list lives in `src/pythinker_code/wire/types.py` (`Event` union): `Ste
99101
| `src/pythinker_code/soul/pythinkersoul.py` | Core loop: user input, slash commands, LLM calls, tool runs, compaction, telemetry spans. | `PythinkerSoul`, `PythinkerSoul.run`, `FLOW_COMMAND_PREFIX` |
100102
| `src/pythinker_code/soul/agent.py` | `Runtime` and `Agent` construction, system-prompt assembly, AGENTS.md discovery. | `Runtime`, `Agent`, `load_agent`, `load_agents_md`, `BuiltinSystemPromptArgs` |
101103
| `src/pythinker_code/soul/context.py` | Conversation history, checkpoints, JSONL persistence. | `Context` |
102-
| `src/pythinker_code/soul/toolset.py` | Loads built-in + MCP tools, injects deps, executes calls. | `PythinkerToolset` |
104+
| `src/pythinker_code/soul/toolset.py` | Owns built-in + MCP registration, visibility, dependency injection, and the legacy per-call facade. | `PythinkerToolset` |
105+
| `src/pythinker_code/soul/tool_execution.py` | Private batch execution state machine: preparation, deduplication, reader/writer scheduling, callbacks, ordered results, bounded cancellation, poison, and late-task recovery. | `ToolExecutionEngine` |
103106
| `src/pythinker_code/soul/slash.py` | Slash-command registry and dispatch. | `registry` |
104107
| `src/pythinker_code/soul/dynamic_injection.py` (+ `dynamic_injections/`) | Injects budgeted `<system-reminder>` content per step: plan-mode, auto-mode, model-defense, LSP diagnostics. | `DynamicInjectionProvider` |
105108
| `src/pythinker_code/soul/permission.py` | Per-step permission profiles (`read_only`/`plan`/`ask`/`implement`/`review`/`verify`) and destructiveness classification. | `tool_destructive_reason`, `shell_command_signature` |
@@ -299,7 +302,7 @@ Both frontends build with `tsc -b && vite build` and are synced into the Python
299302

300303
| Path | Purpose | Key entry points and interfaces |
301304
| --- | --- | --- |
302-
| `packages/pythinker-core/` | LLM abstraction: message models, streaming chat providers, tool abstractions, and the `generate`/`step` primitives. Independently versioned (1.x). | `generate`, `step`, `Message`, `ContentPart`, `ToolCall`, `ChatProvider`, `Toolset`, `ToolReturnValue`/`ToolOk`/`ToolError`, `CallableTool2`, `DisplayBlock`; contrib providers (`Anthropic`, `GoogleGenAI`, `OpenAIResponses`) and `LinearContext` |
305+
| `packages/pythinker-core/` | LLM abstraction: message models, streaming chat providers, optional batch/legacy tool dispatch, and the `generate`/`step` primitives. Independently versioned (1.x). | `generate`, `step`, `StepResult`, `Message`, `ContentPart`, `ToolCall`, `ChatProvider`, `Toolset`, `BatchToolset`, `ToolBatchHandle`, `ToolReturnValue`/`ToolOk`/`ToolError`, `CallableTool2`, `DisplayBlock`; contrib providers (`Anthropic`, `GoogleGenAI`, `OpenAIResponses`) and `LinearContext` |
303306
| `packages/pythinker-host/` | OS abstraction for filesystem + shell across local and SSH backends via a context-var-dispatched `Host` protocol. | `Host`, `HostPath`, `LocalHost`, `HostProcess`, `get_current_host`/`set_current_host` |
304307
| `packages/pythinker-review/` | Standalone review/security/debug engine and stateful Reviewflow; strict Pydantic schemas with fail-closed evidence validation. State in `.pythinker-review/` and `.pythinker-review-flow/`. See `packages/pythinker-review/AGENTS.md`. | `run_engine`, `ReviewLLM`, `Finding`, `RawFinding`, `ReviewerOutput`, Reviewflow `init`/`map`/`review`/`fix` |
305308
| `packages/pythinker-code/` | Thin distribution package exposing the `pythinker-code` script. ||

docs/en/release-notes/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1919

2020
- **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown.
2121
- **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models.
22+
- **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains.
2223

2324
## 0.58.0 (2026-07-11)
2425

0 commit comments

Comments
 (0)