Skip to content

Commit 8f2cb86

Browse files
committed
Merge origin/main: resolve replay-buffer, correlated-event and OpenUI conflicts
2 parents 11eac75 + 8a922b5 commit 8f2cb86

111 files changed

Lines changed: 11151 additions & 1595 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/approval-gate-fixes.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@openrouter/agent': patch
3+
---
4+
5+
Fix two ways the tool-approval gate could be bypassed.
6+
7+
**`allowFinalResponse` executed pending tool calls with no approval check.** When a `stopWhen` condition halted the loop on a turn that still carried tool calls, the final-response path ran those calls directly — skipping the approval gate the normal loop applies on every round. A tool marked `requireApproval: true` (or gated by a predicate) would execute unguarded, and because the `PermissionRequest` hook's deny bookkeeping lives inside the approval check, hook-based `deny` never fired on this path either. That path now runs the same check as the in-loop call sites, so the run pauses with `status: 'awaiting_approval'` and the gated calls on `pendingToolCalls` instead of executing them.
8+
9+
**Function-based `requireApproval` received unvalidated arguments.** Tool-level and call-level predicates were called with the raw JSON-parsed tool arguments, while `execute` receives the arguments *after* the tool's Zod `inputSchema` runs. Any default, coercion, or transform in the schema made them disagree — e.g. with `inputSchema: z.object({ dangerous: z.boolean().default(true) })`, a model emitting `{}` showed a predicate `dangerous: undefined` (no approval required) and then executed with `dangerous: true`. Predicates now see a parsed copy, so they decide on exactly what `execute` will receive without mutating the original executable call or parsing transformed output a second time. `PreToolUse` now runs before every auto-resolvable call is partitioned, so approval hooks and persisted pending calls see its effective arguments. Pending calls record an additive marker when preparation ran, preventing a resumed `ModelResult` from applying the hook twice while legacy state without the marker retains its prior behavior. Call-level checks remain unconditional and receive raw arguments when parsing fails; tool-level checks fail closed when schema parsing fails because a hook may later repair the input.
10+
11+
**Duplicate approval prompts for the same tool call.** The approval gate could run more than once over the same response — e.g. the pre-loop check plus the post-loop `allowFinalResponse` gate when a stop condition fired on the first iteration — re-emitting the `PermissionRequest` hook and re-running `requireApproval` predicates for calls that were already resolved. Each call occurrence in a response is now gated at most once per run, including responses containing duplicate call IDs and arguments.
12+
13+
```ts
14+
import { z } from 'zod/v4';
15+
import { tool, type PendingToolCall } from '@openrouter/agent';
16+
17+
const deploy = tool({
18+
name: 'deploy',
19+
inputSchema: z.object({
20+
environment: z.enum(['staging', 'production']).default('production'),
21+
}),
22+
requireApproval: ({ environment }) => environment === 'production',
23+
execute: async ({ environment }) => deployEnvironment(environment),
24+
});
25+
26+
// `requireApproval` sees the normalized default: { environment: 'production' }.
27+
// Persist this additive marker when PreToolUse already produced effective args.
28+
const pending: PendingToolCall<typeof deploy> = {
29+
id: 'call_deploy',
30+
name: 'deploy',
31+
arguments: { environment: 'production' },
32+
preToolUseApplied: true,
33+
};
34+
```

‎.changeset/quiet-replay-streams.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@openrouter/agent': minor
3+
---
4+
5+
Add opt-in replay compaction and terminal response-event handling for streamed model calls.
6+
7+
```ts
8+
import { callModel } from '@openrouter/agent';
9+
10+
const result = callModel(client, {
11+
model: 'openai/gpt-4o',
12+
input: 'Summarize this document.',
13+
// Retain only the history needed by currently attached consumers.
14+
streamReplay: 'active-consumers',
15+
});
16+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openrouter/agent": patch
3+
---
4+
5+
Add a `./reusable-stream` subpath export so consumers can import `ReusableReadableStream` directly (`@openrouter/agent/reusable-stream`) instead of going through the root barrel or patching the package. Mirrors the existing `./tool-event-broadcaster` entry; both replay classes are the units consumers need when asserting stream-retention behavior against the published package.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@openrouter/agent": patch
3+
---
4+
5+
Remove the runtime `@openrouter/sdk/models` import from `turn-context.ts`. The namespace import existed only to read `EasyInputMessageRoleUser.User` (the string `'user'`), but it made every consumer that statically imports `@openrouter/agent/tool` (via `agent-tool` → `conversation-state` → `turn-context`) evaluate the entire Speakeasy models barrel — hundreds of modules of top-level Zod schema construction — at module load. On Cloudflare Workers this added ~200ms of startup CPU per worker and pushed large workers past the 1s script-validation ceiling (error 10021).
6+
7+
The import is now type-only (erased at compile time) and the role literal is inlined, keeping behavior identical. A new unit test walks the static runtime import graph of the hot subpaths (`/tool`, `/tool-types`, `/stop-conditions`) and fails if any of them ever reaches `@openrouter/sdk` at runtime again.

‎.github/workflows/ci.yaml‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ jobs:
9494
if-no-files-found: ignore
9595
retention-days: 14
9696

97+
package-boundaries:
98+
runs-on: ubuntu-latest
99+
steps:
100+
- uses: actions/checkout@v6
101+
with:
102+
fetch-depth: 2
103+
104+
- uses: pnpm/action-setup@v6
105+
106+
- uses: actions/setup-node@v6
107+
with:
108+
node-version: 22
109+
cache: pnpm
110+
111+
- run: pnpm install --frozen-lockfile
112+
113+
- name: Verify packed package boundaries
114+
run: pnpm verify:packages
115+
97116
e2e-tests:
98117
runs-on: ubuntu-latest
99118
steps:
@@ -166,7 +185,7 @@ jobs:
166185
ci-status:
167186
name: CI status
168187
if: always()
169-
needs: [lint, typecheck, unit-tests, e2e-tests, structural-gate]
188+
needs: [lint, typecheck, unit-tests, e2e-tests, structural-gate, package-boundaries]
170189
runs-on: ubuntu-latest
171190
steps:
172191
- name: Verify all checks passed
@@ -177,6 +196,7 @@ jobs:
177196
unit-tests=${{ needs.unit-tests.result }}
178197
e2e-tests=${{ needs.e2e-tests.result }}
179198
structural-gate=${{ needs.structural-gate.result }}
199+
package-boundaries=${{ needs.package-boundaries.result }}
180200
run: |
181201
failed=0
182202
for entry in $RESULTS; do

‎.sentrux/rules.toml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,23 @@ reason = "api-shape-helpers is a leaf layer; it must not depend on anything abov
4343
from = "packages/agent/src/lib/*"
4444
to = "packages/agent/src/inner-loop/*"
4545
reason = "lib is a utility layer; it must not depend on the inner-loop entry point."
46+
47+
[[boundaries]]
48+
from = "packages/agent/src/inner-loop/*"
49+
to = "packages/agent/src/mcp/*"
50+
reason = "The core agent loop must not load the optional MCP integration."
51+
52+
[[boundaries]]
53+
from = "packages/agent/src/lib/*"
54+
to = "packages/agent/src/mcp/*"
55+
reason = "Core agent utilities must not load the optional MCP integration."
56+
57+
[[boundaries]]
58+
from = "packages/agent/src/api-shape-helpers/*"
59+
to = "packages/agent/src/mcp/*"
60+
reason = "Leaf API-shape definitions must remain independent of optional MCP support."
61+
62+
[[boundaries]]
63+
from = "packages/agent/src/mcp/*"
64+
to = "packages/agent/src/inner-loop/*"
65+
reason = "MCP wraps public tool primitives and must not depend on the model loop."

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Monorepo for the OpenRouter TypeScript agent ecosystem.
88

99
| Package | Path | Description |
1010
| --- | --- | --- |
11-
| [`@openrouter/agent`](./packages/agent) | `packages/agent` | Agent toolkit for building AI applications with OpenRouter — tool orchestration, streaming, multi-turn conversations, and format compatibility. |
11+
| [`@openrouter/agent`](./packages/agent) | `packages/agent` | Agent toolkit with optional `@openrouter/agent/tool-set` and `@openrouter/agent/mcp` subpaths. |
12+
| [`@openrouter/mcp`](./packages/mcp) | `packages/mcp` | Compatibility facade for the canonical `@openrouter/agent/mcp` integration. |
1213

1314
## Development
1415

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"lint": "turbo run lint",
1212
"lint:fix": "biome check --write packages/*/src packages/*/tests",
1313
"typecheck": "turbo run typecheck",
14+
"verify:packages": "node scripts/verify-package-boundaries.mjs",
1415
"changeset": "changeset",
1516
"version": "changeset version && turbo run gen:version",
1617
"prepare": "husky"

‎packages/agent/CHANGELOG.md‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,108 @@
11
# @openrouter/agent
22

3+
## 0.10.0
4+
5+
### Minor Changes
6+
7+
- [#102](https://github.com/OpenRouterTeam/typescript-agent/pull/102) [`787cbf8`](https://github.com/OpenRouterTeam/typescript-agent/commit/787cbf8b22bf2b8071e81e2dbf84ecd871a5e824) Thanks [@LukasParke](https://github.com/LukasParke)! - Add the full MCP integration under the canonical `@openrouter/agent/mcp` subpath. `@modelcontextprotocol/client` is an optional peer, so base agent installations and imports do not install or load MCP support. The existing `@openrouter/mcp` package remains as a compatibility facade and now re-exports the canonical agent subpaths.
8+
9+
```ts
10+
import { callModel, OpenRouter } from "@openrouter/agent";
11+
import { createMCPTools } from "@openrouter/agent/mcp";
12+
13+
const mcp = await createMCPTools({ url: "https://mcp.example.com/mcp" });
14+
const result = callModel(new OpenRouter(), {
15+
model: "openai/gpt-4o-mini",
16+
input: "Use the remote tools.",
17+
tools: mcp.tools,
18+
});
19+
```
20+
21+
Install `@modelcontextprotocol/client` alongside `@openrouter/agent` when using `/mcp`. The SDK is loaded lazily, so importing the base agent or the MCP entry point does not require the peer; the first MCP connection attempt without it throws an actionable `MCPMissingPeerDependencyError`.
22+
23+
Existing `@openrouter/mcp` imports continue to work as tooling-visible deprecated migration facades, but new code should prefer `@openrouter/agent/mcp`. The facade would only be removed in a future breaking release after migration notice.
24+
25+
The `@openrouter/mcp` facade continues to install `@modelcontextprotocol/client` transitively for backward compatibility; only direct `@openrouter/agent/mcp` users need to add the optional peer explicitly.
26+
27+
- [#31](https://github.com/OpenRouterTeam/typescript-agent/pull/31) [`8d2ed61`](https://github.com/OpenRouterTeam/typescript-agent/commit/8d2ed61964aa063936763c7b80f6b5bf389fa144) Thanks [@mattapperson](https://github.com/mattapperson)! - Add `@openrouter/agent/tool-set` (port of ai-tool-set v1.0.0, MIT © Chris Cook): declarative activate / deactivate / activateWhen / deactivateWhen for tools with state- and context-aware predicates. Integrates with a new `activeTools?: readonly string[]` option on `callModel` that filters which tools are sent to the model for a given call.
28+
29+
```ts
30+
import { callModel, OpenRouter, serverTool, tool } from "@openrouter/agent";
31+
import { createToolSet } from "@openrouter/agent/tool-set";
32+
import { z } from "zod/v4";
33+
34+
type AppContext = { accountId: string };
35+
36+
// Curried form preserves the literal name for correlated tool event types.
37+
const listOrders = tool<AppContext>()({
38+
name: "list_orders",
39+
inputSchema: z.object({}),
40+
execute: async (_params, ctx) => ({
41+
accountId: ctx?.shared.accountId,
42+
orders: [],
43+
}),
44+
});
45+
// override the default `server:${type}` id
46+
const search = serverTool(
47+
{ type: "web_search_2025_08_26" },
48+
{ id: "public_search" }
49+
);
50+
51+
const toolSet = createToolSet({
52+
tools: [listOrders, search] as const,
53+
}).deactivate("list_orders");
54+
55+
const client = new OpenRouter({ apiKey: process.env["OPENROUTER_API_KEY"] });
56+
const resolved = toolSet.resolve();
57+
58+
// resolved.callModel is `{ tools, activeTools }` — spread it straight in
59+
const result = callModel(client, {
60+
model: "openai/gpt-4o-mini",
61+
input: "Search for OpenRouter pricing.",
62+
...resolved.callModel,
63+
});
64+
```
65+
66+
- [#114](https://github.com/OpenRouterTeam/typescript-agent/pull/114) [`66d7232`](https://github.com/OpenRouterTeam/typescript-agent/commit/66d7232d53d9881c5842c77f8bc342314724bf3b) Thanks [@mattapperson](https://github.com/mattapperson)! - Add `toolChoice` to `nextTurnParams`, so a tool can change which tools the model may call on the following turn without touching the `tools` array.
67+
68+
This is what a tool-search tool needs: declare every tool up front, keep the not-yet-needed ones out of reach behind an `allowed_tools` choice, and widen that choice as the model discovers what it wants. Because `tools` is byte-identical across turns, the provider's prompt-cache prefix survives — which is the whole reason to withhold tools rather than send them all.
69+
70+
```ts
71+
import { callModel, OpenRouter, tool } from "@openrouter/agent";
72+
import { z } from "zod/v4";
73+
74+
const allowed = (names: string[]) => ({
75+
type: "allowed_tools" as const,
76+
mode: "auto" as const,
77+
tools: names.map((name) => ({ type: "function", name })),
78+
});
79+
80+
const toolSearch = tool({
81+
name: "tool_search",
82+
inputSchema: z.object({ pattern: z.string() }),
83+
execute: ({ pattern }) => findMatchingToolNames(pattern),
84+
nextTurnParams: {
85+
// Append, never rebuild: dropping a name revokes a tool the model may
86+
// already have used, and reordering churns the request for nothing.
87+
toolChoice: ({ pattern }, context) =>
88+
allowed([
89+
...namesIn(context.toolChoice),
90+
...findMatchingToolNames(pattern),
91+
]),
92+
},
93+
});
94+
95+
const client = new OpenRouter({ apiKey: process.env["OPENROUTER_API_KEY"] });
96+
97+
const result = callModel(client, {
98+
model: "openai/gpt-4o-mini",
99+
input: "What is the weather in Tokyo?",
100+
tools: [toolSearch, getWeather, sendEmail, listRepos],
101+
// Only the search tool is reachable until it finds something.
102+
toolChoice: allowed(["tool_search"]),
103+
});
104+
```
105+
3106
## 0.9.0
4107

5108
### Minor Changes

0 commit comments

Comments
 (0)