Skip to content

Commit 456d686

Browse files
CocoRoFclaude
andauthored
feat(s10_tool): add per-call tool events (v0.23.0) (#29)
Extend the Stage 10 event vocabulary with tool.call_start and tool.call_complete so host-side log consumers can render per-call input, outcome, and latency without reaching into pipeline state or re-parsing the Anthropic response. Additive: existing tool.execute_start / tool.execute_complete summary events are preserved byte-for-byte; the new on_event kwarg on ToolExecutor.execute_all is keyword-only and optional (default None matches 0.22.1 semantics). Third-party ToolExecutor implementations continue to work unchanged. Changes: - interface.py: ToolExecutor.execute_all gains on_event kwarg; export ToolEventCallback type alias. - executors.py: SequentialExecutor + ParallelExecutor emit call_start before dispatch and call_complete after, with {tool_use_id, name, input} and {tool_use_id, name, is_error, duration_ms} payloads respectively. - stage.py: ToolStage wires on_event=state.add_event so the new events flow through the existing event-listener path. - 6-test suite validating sequential ordering, parallel pairing by tool_use_id, is_error propagation, on_event=None no-op, and nesting inside tool.execute_* brackets. Full suite: 1015 passed, 18 skipped. Ruff + format clean. Plan ref: Geny/dev_docs/20260420_3/plan/01_immediate_fixes.md PR II. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 273a6c1 commit 456d686

7 files changed

Lines changed: 377 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,75 @@ All notable changes to `geny-executor` are recorded here. The format
44
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
55
this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [0.23.0] — 2026-04-20
8+
9+
Additive release on top of 0.22.1. Extends the Stage 10 tool event
10+
vocabulary with per-call events so downstream log consumers can
11+
render the input, outcome, and latency of individual tool calls.
12+
Prior to 0.23.0 only summary events (`tool.execute_start` /
13+
`tool.execute_complete`) were emitted, forcing hosts like Geny to
14+
either read pipeline-internal state or re-parse the Anthropic
15+
response — both brittle. The 0.23.0 contract is event-level and
16+
stable.
17+
18+
No breaking changes. Existing summary events are preserved
19+
byte-for-byte; consumers that listen only to `tool.execute_*` see
20+
no behavior change. The new `on_event` kwarg on
21+
`ToolExecutor.execute_all` is keyword-only and optional — default
22+
`None` matches 0.22.1 semantics exactly. Third-party executors
23+
implementing `ToolExecutor` continue to work without modification
24+
(they simply don't emit the new events, which was their existing
25+
reality).
26+
27+
### Added
28+
29+
- **`tool.call_start`** event, fired by the default Stage 10
30+
executors (`SequentialExecutor`, `ParallelExecutor`) immediately
31+
before each individual dispatch. Payload:
32+
`{tool_use_id, name, input}` — the full Anthropic-supplied call
33+
id, tool name, and input dict. Paired with `tool.call_complete`
34+
via `tool_use_id`.
35+
- **`tool.call_complete`** event, fired immediately after each
36+
dispatch. Payload: `{tool_use_id, name, is_error, duration_ms}`.
37+
Does not carry the output payload — full results remain on the
38+
message bus (state) to keep the event stream bounded.
39+
- **`on_event` keyword-only kwarg** on
40+
`ToolExecutor.execute_all(...)` (interface + both default
41+
implementations). Shape: `Callable[[str, dict], None]`. The
42+
default `ToolStage` wires it to `state.add_event`, preserving
43+
the existing event-listener path (`state._event_listener`).
44+
- **`ToolEventCallback` type alias** in
45+
`geny_executor.stages.s10_tool.interface`, exported alongside
46+
`ToolExecutor` / `ToolRouter`.
47+
48+
### Why
49+
50+
Host-side log UIs (e.g., Geny's `tool_detail_formatter`) need the
51+
per-call input dict to render a call-by-call detail pane. The
52+
0.22.1 summary events omit this, and the pipeline-internal
53+
`pending_tool_calls` field is not a stable event contract. This
54+
release upgrades the contract so hosts can stop reaching into
55+
pipeline state. See
56+
`Geny/dev_docs/20260420_3/plan/01_immediate_fixes.md` (PR II) for
57+
the design rationale and the full event-vocabulary audit.
58+
59+
### Tests
60+
61+
`tests/unit/test_tool_call_events.py` (new, 6 tests):
62+
63+
- Sequential executor emits `call_start` / `call_complete` per call,
64+
in order, carrying the correct payload.
65+
- `is_error=True` propagates into `call_complete`.
66+
- `on_event=None` (omitted) is a no-op — matches 0.22.1.
67+
- Parallel executor emits paired `call_start` / `call_complete`
68+
events keyed by `tool_use_id`; inter-pair ordering is not
69+
asserted (parallelism).
70+
- `ToolStage` nests per-call events *inside*
71+
`tool.execute_start` / `tool.execute_complete`, preserving the
72+
outer bracket contract.
73+
74+
Full suite: 1015 passed, 18 skipped.
75+
776
## [0.22.1] — 2026-04-20
877

978
CI hygiene patch on top of 0.22.0. No runtime behavior change — same

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "geny-executor"
7-
version = "0.22.1"
7+
version = "0.23.0"
88
description = "Harness-engineered agent pipeline library with 16-stage dual-abstraction architecture, built on the Anthropic API"
99
readme = "README.md"
1010
license = "MIT"

‎src/geny_executor/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
GenyPresets,
8585
)
8686

87-
__version__ = "0.22.1"
87+
__version__ = "0.23.0"
8888

8989
__all__ = [
9090
# Core

‎src/geny_executor/stages/s10_tool/artifact/default/executors.py‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,47 @@
33
from __future__ import annotations
44

55
import asyncio
6-
from typing import Any, Dict, List
6+
import time
7+
from typing import Any, Dict, List, Optional
78

89
from geny_executor.tools.base import ToolContext
9-
from geny_executor.stages.s10_tool.interface import ToolExecutor, ToolRouter
10+
from geny_executor.stages.s10_tool.interface import (
11+
ToolEventCallback,
12+
ToolExecutor,
13+
ToolRouter,
14+
)
15+
16+
17+
def _emit_call_start(on_event: Optional[ToolEventCallback], tc: Dict[str, Any]) -> None:
18+
if on_event is None:
19+
return
20+
on_event(
21+
"tool.call_start",
22+
{
23+
"tool_use_id": tc.get("tool_use_id", ""),
24+
"name": tc.get("tool_name", ""),
25+
"input": tc.get("tool_input", {}),
26+
},
27+
)
28+
29+
30+
def _emit_call_complete(
31+
on_event: Optional[ToolEventCallback],
32+
tc: Dict[str, Any],
33+
result_dict: Dict[str, Any],
34+
duration_ms: int,
35+
) -> None:
36+
if on_event is None:
37+
return
38+
on_event(
39+
"tool.call_complete",
40+
{
41+
"tool_use_id": tc.get("tool_use_id", ""),
42+
"name": tc.get("tool_name", ""),
43+
"is_error": bool(result_dict.get("is_error")),
44+
"duration_ms": duration_ms,
45+
},
46+
)
1047

1148

1249
class SequentialExecutor(ToolExecutor):
@@ -25,15 +62,22 @@ async def execute_all(
2562
tool_calls: List[Dict[str, Any]],
2663
router: ToolRouter,
2764
context: ToolContext,
65+
*,
66+
on_event: Optional[ToolEventCallback] = None,
2867
) -> List[Dict[str, Any]]:
2968
results = []
3069
for tc in tool_calls:
70+
_emit_call_start(on_event, tc)
71+
t0 = time.monotonic()
3172
result = await router.route(
3273
tc["tool_name"],
3374
tc.get("tool_input", {}),
3475
context,
3576
)
36-
results.append(result.to_api_format(tc["tool_use_id"]))
77+
duration_ms = int((time.monotonic() - t0) * 1000)
78+
result_dict = result.to_api_format(tc["tool_use_id"])
79+
_emit_call_complete(on_event, tc, result_dict, duration_ms)
80+
results.append(result_dict)
3781
return results
3882

3983

@@ -56,17 +100,24 @@ async def execute_all(
56100
tool_calls: List[Dict[str, Any]],
57101
router: ToolRouter,
58102
context: ToolContext,
103+
*,
104+
on_event: Optional[ToolEventCallback] = None,
59105
) -> List[Dict[str, Any]]:
60106
semaphore = asyncio.Semaphore(self._max_concurrency)
61107

62108
async def _execute_one(tc: Dict[str, Any]) -> Dict[str, Any]:
63109
async with semaphore:
110+
_emit_call_start(on_event, tc)
111+
t0 = time.monotonic()
64112
result = await router.route(
65113
tc["tool_name"],
66114
tc.get("tool_input", {}),
67115
context,
68116
)
69-
return result.to_api_format(tc["tool_use_id"])
117+
duration_ms = int((time.monotonic() - t0) * 1000)
118+
result_dict = result.to_api_format(tc["tool_use_id"])
119+
_emit_call_complete(on_event, tc, result_dict, duration_ms)
120+
return result_dict
70121

71122
tasks = [_execute_one(tc) for tc in tool_calls]
72123
return list(await asyncio.gather(*tasks))

‎src/geny_executor/stages/s10_tool/artifact/default/stage.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ async def execute(self, input: Any, state: PipelineState) -> Any:
120120
if isinstance(router, RegistryRouter):
121121
router.bind_registry(self._registry)
122122

123-
results = await self._executor.execute_all(tool_calls, router, ctx)
123+
results = await self._executor.execute_all(
124+
tool_calls, router, ctx, on_event=state.add_event
125+
)
124126

125127
state.add_message("user", results)
126128
state.tool_results = results

‎src/geny_executor/stages/s10_tool/interface.py‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from __future__ import annotations
44

55
from abc import abstractmethod
6-
from typing import Any, Dict, List
6+
from typing import Any, Callable, Dict, List, Optional
77

88
from geny_executor.core.stage import Strategy
99
from geny_executor.tools.base import ToolContext, ToolResult
1010

11+
ToolEventCallback = Callable[[str, Dict[str, Any]], None]
12+
1113

1214
class ToolExecutor(Strategy):
1315
"""Base interface for tool execution patterns."""
@@ -18,8 +20,24 @@ async def execute_all(
1820
tool_calls: List[Dict[str, Any]],
1921
router: ToolRouter,
2022
context: ToolContext,
23+
*,
24+
on_event: Optional[ToolEventCallback] = None,
2125
) -> List[Dict[str, Any]]:
22-
"""Execute all pending tool calls. Returns tool_result messages."""
26+
"""Execute all pending tool calls. Returns tool_result messages.
27+
28+
``on_event`` is an optional keyword-only callback invoked with
29+
``(event_type, data)`` for per-call observability events:
30+
31+
- ``tool.call_start`` — fires *before* each dispatch; carries
32+
``{"tool_use_id", "name", "input"}``.
33+
- ``tool.call_complete`` — fires *after* each dispatch; carries
34+
``{"tool_use_id", "name", "is_error", "duration_ms"}``.
35+
36+
When ``on_event`` is ``None`` (default), no per-call events are
37+
emitted and behavior matches pre-0.23.0 semantics. Third-party
38+
executors implementing this protocol are not required to emit
39+
these events — the kwarg is optional.
40+
"""
2341
...
2442

2543

0 commit comments

Comments
 (0)