Skip to content

Commit 8467f1b

Browse files
LukasParkeclaude
andcommitted
port: sync with @openrouter/agent 0.8.0 (680bceb)
Generated by the Upstreamer pipeline in this PR, run locally end to end (scripts/upstream --ref @openrouter/agent@0.8.0). Both gates passed: mechanical verifier === PASS: 0 failures ===, parity eval PASS WITH WARNINGS on the second pass (.upstreamer/eval-report.md). Ports the 0.7.2 -> 0.8.0 delta: lifecycle hooks system (HooksManager + nine built-in hooks), versioned conversation-state serialization, awaiting_client_tools for unresolved manual tool calls, default-on allow_final_response with DEFAULT_FINAL_RESPONSE_DIRECTIVE, strict_final_response / empty-final-retry tolerance, and MCP tool-result source discrimination. state.yaml advances to 680bceb, so the first CI run after merge is a no-op until the next upstream release. First-pass eval FAILed on three real findings (Stop-hook force_resume was not a zero-cost retry, MCP branding surface missing, thin hooks test coverage); all three fixed and re-verified in the second pass — see the eval report for the traced evidence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 66b5c32 commit 8467f1b

31 files changed

Lines changed: 3700 additions & 209 deletions

.upstreamer/eval-report.md

Lines changed: 267 additions & 0 deletions
Large diffs are not rendered by default.

.upstreamer/state.yaml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Last upstream commit successfully ported AND verified AND eval-passed.
2-
# Seeded at @openrouter/agent@0.7.2 (commit adc7939), the release these ports
3-
# were generated from, so the first run is an incremental 0.7.2 -> 0.8.0 delta
4-
# rather than a full from-scratch regeneration.
2+
# 680bceb ported the 0.7.2 -> 0.8.0 delta: the lifecycle hooks system
3+
# (HooksManager, PreToolUse/PostToolUse/PostToolUseFailure/UserPromptSubmit/
4+
# Stop/PermissionRequest/SessionStart/SessionEnd/PostModelCall), versioned
5+
# ConversationState serialization, awaiting_client_tools for unresolved
6+
# manual tool calls, default-on allow_final_response with
7+
# DEFAULT_FINAL_RESPONSE_DIRECTIVE, strict_final_response / empty-final-retry
8+
# tolerance, and MCP tool-result source discrimination.
59
# Only scripts/upstream runs should change this.
6-
upstream_commit: adc7939f4b7ed85b1a060d13433b8be6063cff73
10+
upstream_commit: 680bceb4598f228d3e2ec58e2416e4335cdff059

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ web = server_tool({"type": "web_search_2025_08_26", "max_results": 5})
136136

137137
Use `require_approval` on a tool or `require_approval` on the request to pause sensitive calls before execution. Approval resume requires a state accessor with async `load()` and `save()` methods.
138138

139+
Manual tools (`execute=False`, no `on_tool_called`) pause the loop with status `"awaiting_client_tools"` when the model calls them, instead of silently dropping the call. Read the unresolved calls via `get_pending_tool_calls()` / `get_state()`, execute them yourself, and continue by calling `call_model` again with `function_call_output` items in `input`.
140+
141+
For durable cross-process storage, serialize state with `serialize_conversation_state` / `deserialize_conversation_state` rather than storing raw dataclass fields. The wire format is versioned (`CONVERSATION_STATE_VERSION`); a version mismatch raises `UnsupportedStateVersionError` and malformed JSON raises `InvalidStateError`, so a store can never silently misinterpret a future shape.
142+
139143
Tool context is kept outside the model transcript. Provide a context mapping with per-tool keys and optional `shared` state. Tool execution receives `ctx["local"]`, `ctx["shared"]`, `ctx["set_context"]`, and `ctx["set_shared_context"]`.
140144

141145
```python
@@ -151,6 +155,22 @@ result = call_model(
151155
)
152156
```
153157

158+
## Lifecycle Hooks
159+
160+
Pass a `HooksManager` (or an inline `{hook_name: [HookEntry(...)]}` dict of built-in hooks) via `hooks=` to observe or intervene in a run: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, and `PostModelCall`. Handlers receive a validated payload dict and a `LifecycleHookContext` (`session_id`, `hook_name`, `cancel_event`).
161+
162+
```python
163+
from openrouter_agent import HookEntry, HookName, HooksManager
164+
165+
hooks = HooksManager()
166+
hooks.on(HookName.PreToolUse.value, HookEntry(handler=lambda payload, ctx: None, matcher="delete_file"))
167+
hooks.on(HookName.SessionEnd.value, HookEntry(handler=lambda payload, ctx: print(payload["total_usage"])))
168+
169+
result = call_model(client, {"model": model, "input": prompt, "tools": tools, "hooks": hooks})
170+
```
171+
172+
`SessionStart` fires once per run with a config summary; `SessionEnd` fires once with aggregated `total_usage` (summed across every `PostModelCall`) and is guaranteed to fire — and any pending async hook work drained — even when a no-tools stream raises. `PreToolUse` can block a call (`{"block": "reason"}`) or mutate its input (`{"mutated_input": {...}}`); `PermissionRequest` can pre-empt the approval gate with `{"decision": "allow" | "deny" | "ask_user"}`; `Stop` can force the loop to keep going past a `stop_when` hit with `{"force_resume": True, "append_prompt": "..."}`. A `HooksManager` instance is safe to share across concurrent `call_model` runs — session identity is threaded per emit, not stored as manager-level mutable state.
173+
154174
## Stop Conditions
155175

156176
The built-ins mirror the TypeScript package and OR together when provided as a list:
@@ -161,7 +181,7 @@ The built-ins mirror the TypeScript package and OR together when provided as a l
161181
- `max_cost(dollars)`
162182
- `finish_reason_is(reason)`
163183

164-
Set `allow_final_response=True` or a string to ask for one final no-tools turn when a stop condition fires on a tool-call turn.
184+
When a stop condition fires while the model is still emitting tool calls, `call_model` makes one more turn with `tool_choice="none"` by default (tools stay in the request so the prompt-cache prefix survives) so the run ends with a natural-language answer. `allow_final_response` tunes this: `True` or omitted appends `DEFAULT_FINAL_RESPONSE_DIRECTIVE` as a user message, a non-empty string replaces the wording, `""` appends nothing, and `False` disables the extra turn entirely.
165185

166186
## Format Compatibility
167187

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 = "openrouter-agent"
7-
version = "0.7.2"
7+
version = "0.8.0"
88
description = "Python port of @openrouter/agent: OpenRouter tool orchestration, streaming, state, and format compatibility."
99
readme = "README.md"
1010
requires-python = ">=3.9.2"

src/openrouter_agent/__init__.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,54 @@
6565
from .claude_constants import ClaudeContentBlockType, NonClaudeMessageRole
6666
from .claude_type_guards import is_claude_style_messages
6767
from .conversation_state import (
68+
CONVERSATION_STATE_VERSION,
69+
InvalidStateError,
70+
UnsupportedStateVersionError,
6871
append_to_messages,
6972
create_initial_state,
7073
create_rejected_result,
7174
create_unsent_result,
75+
deserialize_conversation_state,
7276
generate_conversation_id,
7377
partition_tool_calls,
78+
serialize_conversation_state,
7479
tool_requires_approval,
7580
update_state,
7681
)
82+
from .hooks_manager import HooksManager
83+
from .hooks_schemas import (
84+
HookDefinition,
85+
HookName,
86+
HookRegistry,
87+
ModelCallUsage,
88+
PermissionRequestPayload,
89+
PermissionRequestResult,
90+
PostModelCallPayload,
91+
PostToolUseFailurePayload,
92+
PostToolUsePayload,
93+
PreToolUsePayload,
94+
PreToolUseResult,
95+
SessionEndPayload,
96+
SessionStartPayload,
97+
SessionUsageTotals,
98+
StopPayload,
99+
StopResult,
100+
UserPromptSubmitPayload,
101+
UserPromptSubmitResult,
102+
)
103+
from .hooks_types import (
104+
DEFAULT_ASYNC_TIMEOUT_MS,
105+
HOOK_BEHAVIOR,
106+
AsyncOutput,
107+
EmitResult,
108+
HookBehavior,
109+
HookEntry,
110+
HookHandler,
111+
InlineHookConfig,
112+
LifecycleHookContext,
113+
ToolMatcher,
114+
is_async_output,
115+
)
77116
from .item_types import (
78117
AssistantMessageItem,
79118
CallFileSearchItem,
@@ -90,7 +129,7 @@
90129
SystemMessageItem,
91130
UserMessageItem,
92131
)
93-
from .model_result import GetResponseOptions, ModelResult
132+
from .model_result import DEFAULT_FINAL_RESPONSE_DIRECTIVE, GetResponseOptions, ModelResult
94133
from .next_turn_params import (
95134
apply_next_turn_params_to_request,
96135
build_next_turn_params_context,
@@ -111,7 +150,7 @@
111150
get_unsupported_content_summary,
112151
has_unsupported_content,
113152
)
114-
from .tool import server_tool, tool
153+
from .tool import mark_mcp, server_tool, tool
115154
from .tool_context import ContextInput, ToolContextStore, build_tool_execute_context
116155
from .tool_event_broadcaster import ToolEventBroadcaster
117156
from .tool_types import (
@@ -129,6 +168,7 @@
129168
InferToolOutput,
130169
InferToolOutputsUnion,
131170
ManualTool,
171+
McpBranded,
132172
NextTurnParamsContext,
133173
NextTurnParamsFunctions,
134174
ParsedToolCall,
@@ -172,6 +212,7 @@
172212
is_generator_tool,
173213
is_hitl_tool,
174214
is_manual_tool,
215+
is_mcp_tool,
175216
is_regular_execute_tool,
176217
is_server_tool,
177218
is_tool_call_output_event,
@@ -202,11 +243,13 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
202243
"AfterSuccessContext",
203244
"AfterSuccessHook",
204245
"AssistantMessageItem",
246+
"AsyncOutput",
205247
"BaseInputsUnion",
206248
"BeforeCreateRequestContext",
207249
"BeforeCreateRequestHook",
208250
"BeforeRequestContext",
209251
"BeforeRequestHook",
252+
"CONVERSATION_STATE_VERSION",
210253
"CallFileSearchItem",
211254
"CallFunctionToolItem",
212255
"CallImageGenerationItem",
@@ -220,11 +263,14 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
220263
"ClientTool",
221264
"ConversationState",
222265
"ConversationStatus",
266+
"DEFAULT_ASYNC_TIMEOUT_MS",
267+
"DEFAULT_FINAL_RESPONSE_DIRECTIVE",
223268
"DeveloperMessageItem",
224269
"EasyInputMessage",
225270
"EasyInputMessageContentInputImage",
226271
"EasyInputMessageContentUnion1",
227272
"EasyInputMessageRoleUnion",
273+
"EmitResult",
228274
"EnhancedResponseStreamEvent",
229275
"ErrorEvent",
230276
"ErrorItem",
@@ -235,23 +281,36 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
235281
"GetResponseOptions",
236282
"HITLTool",
237283
"HITLToolFunction",
284+
"HOOK_BEHAVIOR",
238285
"HasApprovalTools",
239286
"Hook",
287+
"HookBehavior",
240288
"HookContext",
289+
"HookDefinition",
290+
"HookEntry",
291+
"HookHandler",
292+
"HookName",
293+
"HookRegistry",
294+
"HooksManager",
241295
"InferToolEvent",
242296
"InferToolEventsUnion",
243297
"InferToolInput",
244298
"InferToolOutput",
245299
"InferToolOutputsUnion",
300+
"InlineHookConfig",
246301
"InputAudio",
247302
"InputFile",
248303
"InputImage",
249304
"InputMessageItem",
250305
"InputText",
251306
"InputVideo",
252307
"InputsUnion",
308+
"InvalidStateError",
253309
"Item",
310+
"LifecycleHookContext",
254311
"ManualTool",
312+
"McpBranded",
313+
"ModelCallUsage",
255314
"ModelResult",
256315
"NewUserMessageItem",
257316
"NextTurnParamsContext",
@@ -272,6 +331,13 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
272331
"OutputWebSearchCallItem",
273332
"ParsedToolCall",
274333
"PartialResponse",
334+
"PermissionRequestPayload",
335+
"PermissionRequestResult",
336+
"PostModelCallPayload",
337+
"PostToolUseFailurePayload",
338+
"PostToolUsePayload",
339+
"PreToolUsePayload",
340+
"PreToolUseResult",
275341
"ReasoningItem",
276342
"RequestOptions",
277343
"ResolvedCallModelInput",
@@ -286,9 +352,14 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
286352
"ServerToolConfig",
287353
"ServerToolResultItem",
288354
"ServerToolType",
355+
"SessionEndPayload",
356+
"SessionStartPayload",
357+
"SessionUsageTotals",
289358
"StateAccessor",
290359
"StepResult",
291360
"StopCondition",
361+
"StopPayload",
362+
"StopResult",
292363
"StopWhen",
293364
"StreamEvents",
294365
"StreamableOutputItem",
@@ -303,6 +374,7 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
303374
"ToolExecutionResult",
304375
"ToolExecutionResultUnion",
305376
"ToolHasApproval",
377+
"ToolMatcher",
306378
"ToolOutputContentItem",
307379
"ToolPreliminaryResultEvent",
308380
"ToolResultEvent",
@@ -317,8 +389,11 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
317389
"TypedToolCall",
318390
"TypedToolCallUnion",
319391
"UnsentToolResult",
392+
"UnsupportedStateVersionError",
320393
"Usage",
321394
"UserMessageItem",
395+
"UserPromptSubmitPayload",
396+
"UserPromptSubmitResult",
322397
"Warning",
323398
"append_to_messages",
324399
"apply_next_turn_params_to_request",
@@ -329,6 +404,7 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
329404
"create_initial_state",
330405
"create_rejected_result",
331406
"create_unsent_result",
407+
"deserialize_conversation_state",
332408
"execute_next_turn_params_functions",
333409
"extract_unsupported_content",
334410
"finish_reason_is",
@@ -341,12 +417,14 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
341417
"has_execute_function",
342418
"has_tool_call",
343419
"has_unsupported_content",
420+
"is_async_output",
344421
"is_auto_resolvable_tool",
345422
"is_client_tool",
346423
"is_claude_style_messages",
347424
"is_generator_tool",
348425
"is_hitl_tool",
349426
"is_manual_tool",
427+
"is_mcp_tool",
350428
"is_regular_execute_tool",
351429
"is_server_tool",
352430
"is_stop_condition_met",
@@ -355,11 +433,13 @@ def before_create_request(self, hook_ctx: BeforeCreateRequestContext, request: A
355433
"is_tool_result_event",
356434
"is_turn_end_event",
357435
"is_turn_start_event",
436+
"mark_mcp",
358437
"max_cost",
359438
"max_tokens_used",
360439
"normalize_input_to_array",
361440
"partition_tool_calls",
362441
"resolve_async_functions",
442+
"serialize_conversation_state",
363443
"server_tool",
364444
"step_count_is",
365445
"to_chat_message",

src/openrouter_agent/async_params.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class CallModelInput(TypedDict, total=False):
1919
context: Mapping[str, Any]
2020
shared_context_schema: Any
2121
allow_final_response: Any
22+
strict_final_response: bool
23+
hooks: Any
2224

2325

2426
CallModelInputWithState = CallModelInput
@@ -42,6 +44,8 @@ class ResolvedCallModelInput(TypedDict, total=False):
4244
"on_turn_start",
4345
"on_turn_end",
4446
"allow_final_response",
47+
"strict_final_response",
48+
"hooks",
4549
}
4650

4751

src/openrouter_agent/call_model.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import Any, Mapping, Optional
44

5+
from .hooks_resolve import resolve_hooks
56
from .model_result import ModelResult
67
from .tool_executor import convert_tools_to_api_format
78

@@ -21,6 +22,8 @@ def call_model(client: Any, request: Mapping[str, Any], options: Optional[Mappin
2122
"on_turn_start",
2223
"on_turn_end",
2324
"allow_final_response",
25+
"strict_final_response",
26+
"hooks",
2427
):
2528
final_request.pop(key, None)
2629
if tools is not None:
@@ -45,5 +48,7 @@ def call_model(client: Any, request: Mapping[str, Any], options: Optional[Mappin
4548
"on_turn_start": request.get("on_turn_start"),
4649
"on_turn_end": request.get("on_turn_end"),
4750
"allow_final_response": request.get("allow_final_response"),
51+
"strict_final_response": request.get("strict_final_response"),
52+
"hooks": resolve_hooks(request.get("hooks")),
4853
}
4954
)

0 commit comments

Comments
 (0)