@springbrand/agent-runtime 0.2.0-alpha.30 → 0.2.0-alpha.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.30",
3
+ "version": "0.2.0-alpha.31",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -889,7 +889,7 @@ export interface RuntimeBrowserPort {
889
889
  */
890
890
  export type RuntimeModelProtocol =
891
891
  | "openai-chat"
892
- | "openrouter-chat"
892
+ | "openrouter-messages"
893
893
  | "anthropic-messages"
894
894
  | "google-generative-ai"
895
895
  | "openai-codex-responses";
package/src/lib/prompt.ts CHANGED
@@ -50,6 +50,8 @@ export const TOOLS =
50
50
  "or branching or repetition MUST use one execute. Do not make repeated top-level calls for that work. Inside execute, use state.* for " +
51
51
  "workspace file operations, tools.* for other host capabilities, a loop for repeated calls, and Promise.all for independent calls. " +
52
52
  "Tools available only at the top level must stay Direct. " +
53
+ "Some Tools may be deferred and absent from the initial list. When Tool Search is available, before saying that a requested capability or named Tool is unavailable, use the available Tool Search once. " +
54
+ "If the user names a Tool, search that exact name by itself first. Searchable categories may include workspace and files, browser and web, interaction and planning, memory and scheduling, Skills, Extensions, MCP, Plugins, and Host capabilities; discover them only when needed. " +
53
55
  "For web tasks, use web_search for web discovery, current facts, cited research, " +
54
56
  "and public URL analysis. For web access specifically, use execute Code Mode only for raw or customized network requests, structured " +
55
57
  "API calls, or when web_search cannot retrieve the required content; do not use execute for ordinary web " +
@@ -256,6 +256,7 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
256
256
  ...(candidate.alwaysRequiresApproval
257
257
  ? { alwaysRequiresApproval: true }
258
258
  : {}),
259
+ ...(candidate.deferLoading ? { deferLoading: true } : {}),
259
260
  ...(candidate.source ? { source: candidate.source } : {}),
260
261
  retry: piToolRetryPolicy(candidate),
261
262
  }))
@@ -10,9 +10,7 @@ import {
10
10
  type Model,
11
11
  type MutableModels,
12
12
  } from "@earendil-works/pi-ai";
13
- import {
14
- anthropicMessagesApi,
15
- } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
13
+ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
16
14
  import {
17
15
  openAICompletionsApi,
18
16
  } from "@earendil-works/pi-ai/api/openai-completions.lazy";
@@ -48,10 +46,11 @@ import type {
48
46
  RuntimeModelProtocol,
49
47
  RuntimeProviderPort,
50
48
  } from "../../kernel/bindings";
49
+ import { openRouterMessagesApi } from "./openrouter-messages";
51
50
 
52
51
  const CATALOGS = {
53
52
  "openai-chat": openaiProvider().getModels(),
54
- "openrouter-chat": openrouterProvider().getModels(),
53
+ "openrouter-messages": openrouterProvider().getModels(),
55
54
  "anthropic-messages": anthropicProvider().getModels(),
56
55
  "google-generative-ai": googleProvider().getModels(),
57
56
  "openai-codex-responses": openaiCodexProvider().getModels(),
@@ -560,8 +559,8 @@ function providerId(endpoint: RuntimeModelEndpoint, index: number): string {
560
559
  function apiFor(protocol: RuntimeModelProtocol): Api {
561
560
  switch (protocol) {
562
561
  case "openai-chat":
563
- case "openrouter-chat":
564
562
  return "openai-completions";
563
+ case "openrouter-messages":
565
564
  case "anthropic-messages":
566
565
  return "anthropic-messages";
567
566
  case "google-generative-ai":
@@ -574,8 +573,9 @@ function apiFor(protocol: RuntimeModelProtocol): Api {
574
573
  function piApiFor(protocol: RuntimeModelProtocol) {
575
574
  switch (protocol) {
576
575
  case "openai-chat":
577
- case "openrouter-chat":
578
576
  return openAICompletionsApi();
577
+ case "openrouter-messages":
578
+ return openRouterMessagesApi();
579
579
  case "anthropic-messages":
580
580
  return anthropicMessagesApi();
581
581
  case "google-generative-ai":
@@ -619,12 +619,11 @@ function configuredModel(
619
619
  api: apiFor(endpoint.protocol),
620
620
  provider: providerId(endpoint, index),
621
621
  baseUrl: endpoint.baseURL,
622
- ...(endpoint.protocol === "openrouter-chat"
622
+ ...(endpoint.protocol === "openrouter-messages"
623
623
  ? {
624
624
  compat: {
625
- ...compat,
626
- sendSessionAffinityHeaders: true,
627
- sessionAffinityFormat: "openrouter" as const,
625
+ supportsEagerToolInputStreaming: false,
626
+ supportsToolReferences: true,
628
627
  ...(openRouterProviderPin
629
628
  ? {
630
629
  openRouterRouting: {
@@ -0,0 +1,127 @@
1
+ import type {
2
+ Context,
3
+ Model,
4
+ ProviderStreams,
5
+ StreamOptions,
6
+ } from "@earendil-works/pi-ai";
7
+ import {
8
+ anthropicMessagesApi,
9
+ } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
10
+
11
+ type Payload = Record<string, unknown>;
12
+
13
+ function record(value: unknown): Payload | undefined {
14
+ return value !== null && typeof value === "object"
15
+ ? value as Payload
16
+ : undefined;
17
+ }
18
+
19
+ function decoratePayload(
20
+ payload: unknown,
21
+ model: Model<"anthropic-messages">,
22
+ context: Context,
23
+ sessionId?: string,
24
+ ): unknown {
25
+ const body = record(payload);
26
+ if (!body) throw new Error("OpenRouter Messages payload must be an object");
27
+
28
+ const deferredNames = new Set(
29
+ context.tools?.filter((tool) => tool.deferLoading).map((tool) => tool.name),
30
+ );
31
+ const tools = Array.isArray(body.tools) ? body.tools : [];
32
+ const immediate: unknown[] = [];
33
+ const deferred: unknown[] = [];
34
+ for (const tool of tools) {
35
+ const definition = record(tool);
36
+ if (definition && typeof definition.name === "string" && deferredNames.has(definition.name)) {
37
+ deferred.push({ ...definition, defer_loading: true });
38
+ } else {
39
+ immediate.push(tool);
40
+ }
41
+ }
42
+
43
+ if (deferred.length > 0) {
44
+ const choice = record(body.tool_choice);
45
+ if (
46
+ body.tool_choice !== undefined &&
47
+ body.tool_choice !== "auto" &&
48
+ choice?.type !== "auto"
49
+ ) {
50
+ throw new Error(
51
+ "tool_choice conflicts with openrouter:tool_search; omit it or use auto",
52
+ );
53
+ }
54
+ }
55
+
56
+ return {
57
+ ...body,
58
+ ...(sessionId ? { session_id: sessionId } : {}),
59
+ ...(model.compat?.openRouterRouting
60
+ ? { provider: model.compat.openRouterRouting }
61
+ : {}),
62
+ ...(deferred.length > 0
63
+ ? {
64
+ tools: [
65
+ {
66
+ type: "openrouter:tool_search",
67
+ parameters: { max_results: 5 },
68
+ },
69
+ ...immediate,
70
+ ...deferred,
71
+ ],
72
+ }
73
+ : {}),
74
+ };
75
+ }
76
+
77
+ function openRouterOptions(
78
+ model: Model<"anthropic-messages">,
79
+ context: Context,
80
+ options: StreamOptions | undefined,
81
+ ): StreamOptions {
82
+ const apiKey = options?.apiKey;
83
+ const headers = Object.fromEntries(
84
+ Object.entries(options?.headers ?? {}).filter(([name]) =>
85
+ !["authorization", "x-api-key"].includes(name.toLowerCase())
86
+ ),
87
+ );
88
+ return {
89
+ ...options,
90
+ apiKey: undefined,
91
+ headers: {
92
+ ...headers,
93
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
94
+ "x-api-key": null,
95
+ },
96
+ onPayload: async (payload, activeModel) => {
97
+ const callerPayload = await options?.onPayload?.(payload, activeModel) ?? payload;
98
+ return decoratePayload(callerPayload, model, context, options?.sessionId);
99
+ },
100
+ };
101
+ }
102
+
103
+ export function openRouterMessagesApi(): ProviderStreams {
104
+ const anthropic = anthropicMessagesApi();
105
+ return {
106
+ stream: (model, context, options) =>
107
+ anthropic.stream(
108
+ model,
109
+ context,
110
+ openRouterOptions(
111
+ model as Model<"anthropic-messages">,
112
+ context,
113
+ options,
114
+ ),
115
+ ),
116
+ streamSimple: (model, context, options) =>
117
+ anthropic.streamSimple(
118
+ model,
119
+ context,
120
+ openRouterOptions(
121
+ model as Model<"anthropic-messages">,
122
+ context,
123
+ options,
124
+ ),
125
+ ),
126
+ };
127
+ }
@@ -0,0 +1,45 @@
1
+ event: message_start
2
+ data: {"type":"message_start","message":{"id":"msg_tool_search","type":"message","role":"assistant","model":"anthropic/claude-sonnet-5","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
3
+
4
+ event: content_block_start
5
+ data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}
6
+
7
+ event: content_block_delta
8
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"I need the hidden status tool."}}
9
+
10
+ event: content_block_delta
11
+ data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signed-thinking"}}
12
+
13
+ event: content_block_stop
14
+ data: {"type":"content_block_stop","index":0}
15
+
16
+ event: content_block_start
17
+ data: {"type":"content_block_start","index":1,"content_block":{"type":"server_tool_use","id":"srvtoolu_search_1","name":"tool_search_tool_regex","input":{}}}
18
+
19
+ event: content_block_delta
20
+ data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"pattern\":\"status\"}"}}
21
+
22
+ event: content_block_stop
23
+ data: {"type":"content_block_stop","index":1}
24
+
25
+ event: content_block_start
26
+ data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_search_1","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"deferred_status"}]}}}
27
+
28
+ event: content_block_stop
29
+ data: {"type":"content_block_stop","index":2}
30
+
31
+ event: content_block_start
32
+ data: {"type":"content_block_start","index":3,"content_block":{"type":"tool_use","id":"toolu_status_1","name":"deferred_status","input":{}}}
33
+
34
+ event: content_block_delta
35
+ data: {"type":"content_block_delta","index":3,"delta":{"type":"input_json_delta","partial_json":"{\"scope\":\"runtime\"}"}}
36
+
37
+ event: content_block_stop
38
+ data: {"type":"content_block_stop","index":3}
39
+
40
+ event: message_delta
41
+ data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":24}}
42
+
43
+ event: message_stop
44
+ data: {"type":"message_stop"}
45
+
@@ -46,6 +46,8 @@ export interface PiToolInteractionSpec {
46
46
  export interface PiToolCandidate {
47
47
  readonly owner: string;
48
48
  readonly tool: AgentTool<any, any>;
49
+ /** @internal Send the complete schema but hide it until provider Tool Search finds it. */
50
+ readonly deferLoading?: true;
49
51
  /** Keep this Tool Direct-only instead of also offering it through Code Mode. */
50
52
  readonly direct?: true;
51
53
  /** Offer this Tool only through Code Mode, never as a top-level Tool. */
@@ -314,6 +316,7 @@ function governedTool(
314
316
 
315
317
  return {
316
318
  ...candidate.tool,
319
+ ...(candidate.deferLoading ? { deferLoading: true as const } : {}),
317
320
  // 执行一次完整的受治理 Pi 工具调用。
318
321
  // Pi 工具循环选中编译后的工具时调用,调用方应传入稳定 toolCall id 供结算去重。
319
322
  // 输出限额必须发生在 settle 之前,否则 Durable Object 会持久化一份与模型最终所见不同的过大结果。
@@ -1119,7 +1119,7 @@ export interface WebSearchOptions {
1119
1119
 
1120
1120
  function apiFor(endpoint: RuntimeModelEndpoint): NativeApi {
1121
1121
  switch (endpoint.protocol) {
1122
- case "openrouter-chat":
1122
+ case "openrouter-messages":
1123
1123
  return "openrouter-responses";
1124
1124
  case "google-generative-ai":
1125
1125
  return "google-generative-ai";
@@ -191,9 +191,17 @@ async function createToolSurface(
191
191
  const deny = new Set(input.policy?.denyPolicy?.deny ?? []);
192
192
  const allowsTool = input.policy?.allowsTool;
193
193
  const allowsExtension = input.policy?.allowsExtension;
194
+ const defersTool = input.policy?.defersTool;
194
195
  const visible = (candidate: PiToolCandidate) =>
195
196
  !deny.has(candidate.tool.name) &&
196
197
  allowsTool?.(candidate.tool.name) !== false;
198
+ const classify = (candidate: PiToolCandidate): PiToolCandidate =>
199
+ Object.freeze({
200
+ ...candidate,
201
+ ...(defersTool?.(candidate.tool.name) === true
202
+ ? { deferLoading: true as const }
203
+ : { deferLoading: undefined }),
204
+ });
197
205
  // 平台没有浏览器能力时注册空集:宁可没有这个 Tool,也不注册一个必然失败的 Tool 误导模型。
198
206
  // `create()` 推迟到确认这个名字真的可见之后,被 deny 的装配不白建一个浏览器连接器。
199
207
  // `direct` 使它只走 Direct 调用,不再被并进 `execute` 的工具集——两个 Code Mode 类工具互相嵌套
@@ -259,7 +267,7 @@ async function createToolSurface(
259
267
  deny.has("execute") ||
260
268
  allowsTool?.("execute") === false
261
269
  ) {
262
- return Object.freeze(directVisible);
270
+ return Object.freeze(directVisible.map(classify));
263
271
  }
264
272
  const mergeable = finalized.filter(
265
273
  (candidate) =>
@@ -280,7 +288,7 @@ async function createToolSurface(
280
288
  "Use a loop or Promise.all when copying multiple resources.\n\n"
281
289
  : "";
282
290
  return Object.freeze([
283
- Object.freeze({
291
+ classify({
284
292
  ...codeExecutionPiToolCandidate(
285
293
  {
286
294
  ...codeExecution,
@@ -289,7 +297,7 @@ async function createToolSurface(
289
297
  ),
290
298
  codeExecutionTools,
291
299
  }),
292
- ...directVisible,
300
+ ...directVisible.map(classify),
293
301
  ]);
294
302
  },
295
303
  }),
@@ -831,12 +839,18 @@ export async function assembleRuntimeSnapshot<
831
839
  ...(settings.toolPolicy?.allowsExtension
832
840
  ? { allowsExtension: settings.toolPolicy.allowsExtension }
833
841
  : {}),
842
+ ...(settings.toolPolicy?.defersTool
843
+ ? { defersTool: settings.toolPolicy.defersTool }
844
+ : {}),
834
845
  ...(toolAssembly.surfacePolicy?.allowsTool
835
846
  ? { allowsTool: toolAssembly.surfacePolicy.allowsTool }
836
847
  : {}),
837
848
  ...(toolAssembly.surfacePolicy?.allowsExtension
838
849
  ? { allowsExtension: toolAssembly.surfacePolicy.allowsExtension }
839
850
  : {}),
851
+ ...(toolAssembly.surfacePolicy?.defersTool
852
+ ? { defersTool: toolAssembly.surfacePolicy.defersTool }
853
+ : {}),
840
854
  }
841
855
  : undefined;
842
856
 
@@ -28,6 +28,7 @@ import type {
28
28
  RuntimeAgentPlanningContext,
29
29
  } from "./runtime-agent-context";
30
30
  import type { AgentTelemetryBinding } from "./telemetry/contract";
31
+ import type { ToolSurfaceSelectionPolicy } from "./tool-registry";
31
32
 
32
33
  export interface RuntimeProfileContribution {
33
34
  readonly model: string;
@@ -48,10 +49,8 @@ export interface RuntimeExtensionContribution {
48
49
  readonly extension: RuntimeExtensionConfig;
49
50
  }
50
51
 
51
- export interface RuntimeToolSurfacePolicy {
52
+ export interface RuntimeToolSurfacePolicy extends ToolSurfaceSelectionPolicy {
52
53
  readonly denyPolicy?: RuntimeDenyPolicy;
53
- readonly allowsTool?: (name: string) => boolean;
54
- readonly allowsExtension?: (extension: RuntimeExtensionConfig) => boolean;
55
54
  }
56
55
 
57
56
  export type { RuntimeAgentContext, RuntimeAssemblyContext } from "./runtime-agent-context";
package/src/runtime.ts CHANGED
@@ -147,7 +147,7 @@ export const CHAT_RECOVERY_MAX_ATTEMPTS = 5;
147
147
  // 那种情况下 5 次 × 240s ≈ 20 分钟的空转纯属折磨用户。3 次约 12 分钟封顶。
148
148
  export const CHAT_STALL_MAX_ATTEMPTS = 3;
149
149
  const CHAT_RECOVERY_TERMINAL_MESSAGE =
150
- "多次恢复仍未成功,本次生成已停止,当前进度已保留。请发送新消息继续。";
150
+ "Recovery failed after multiple attempts. This generation has stopped and your progress has been saved. Send a new message to continue.";
151
151
 
152
152
  type RuntimeEventOutboxPayload =
153
153
  | { readonly type: "model-usage"; readonly event: RuntimeModelUsageEvent }
@@ -27,6 +27,7 @@ export type {
27
27
  export interface ToolSurfaceSelectionPolicy {
28
28
  readonly allowsTool?: (name: string) => boolean;
29
29
  readonly allowsExtension?: (extension: RuntimeExtensionConfig) => boolean;
30
+ readonly defersTool?: (name: string) => boolean;
30
31
  }
31
32
 
32
33
  export function emptyToolRegistry(): ToolRegistry {