@springbrand/agent-runtime 0.1.0 → 0.1.2

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.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -759,6 +759,7 @@ export interface RuntimeBrowserPort {
759
759
  */
760
760
  export type RuntimeModelProtocol =
761
761
  | "openai-chat"
762
+ | "openrouter-chat"
762
763
  | "anthropic-messages"
763
764
  | "google-generative-ai"
764
765
  | "openai-codex-responses";
@@ -84,7 +84,6 @@ export interface RuntimeProfile {
84
84
  denyPolicy?: RuntimeDenyPolicy;
85
85
  enabledSubagents: readonly string[];
86
86
  mcpServers: readonly RuntimeMcpServer[];
87
- enabledExtensions: readonly string[];
88
87
  executionLevel: ExecutionLevel;
89
88
  memory: RuntimeMemoryProfile;
90
89
  }
@@ -109,7 +108,6 @@ export function freezeRuntimeProfile(profile: RuntimeProfile): RuntimeProfile {
109
108
  : undefined,
110
109
  enabledSubagents: Object.freeze([...profile.enabledSubagents]),
111
110
  mcpServers: Object.freeze(profile.mcpServers.map((server) => Object.freeze({ ...server }))),
112
- enabledExtensions: Object.freeze([...profile.enabledExtensions]),
113
111
  memory: Object.freeze({ ...profile.memory }),
114
112
  };
115
113
  return Object.freeze(frozen);
@@ -45,7 +45,7 @@ interface PiAssemblySnapshot {
45
45
  >;
46
46
  readonly profile: Pick<
47
47
  RuntimeProfile,
48
- "denyPolicy" | "enabledExtensions" | "mcpServers"
48
+ "denyPolicy" | "mcpServers"
49
49
  >;
50
50
  readonly bindings: {
51
51
  readonly platform: Pick<RuntimePlatformPort, "loader">;
@@ -453,7 +453,7 @@ async function preparePiAssembly(
453
453
  const extensions = await assemblePiExtensions({
454
454
  extensions: snapshot.pi.extensions,
455
455
  published: extensionNames,
456
- enabled: new Set(snapshot.profile.enabledExtensions),
456
+ enabled: extensionNames,
457
457
  authorized: extensionNames,
458
458
  load: async (extension) => {
459
459
  const workspacePermission =
@@ -2,7 +2,9 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import {
3
3
  createModels,
4
4
  createProvider,
5
+ lazyStream,
5
6
  type Api,
7
+ type AssistantMessageEvent,
6
8
  type Model,
7
9
  type MutableModels,
8
10
  } from "@earendil-works/pi-ai";
@@ -24,6 +26,9 @@ import {
24
26
  import {
25
27
  openaiProvider,
26
28
  } from "@earendil-works/pi-ai/providers/openai";
29
+ import {
30
+ openrouterProvider,
31
+ } from "@earendil-works/pi-ai/providers/openrouter";
27
32
  import {
28
33
  googleProvider,
29
34
  } from "@earendil-works/pi-ai/providers/google";
@@ -38,25 +43,64 @@ import type {
38
43
 
39
44
  const CATALOGS = {
40
45
  "openai-chat": openaiProvider().getModels(),
46
+ "openrouter-chat": openrouterProvider().getModels(),
41
47
  "anthropic-messages": anthropicProvider().getModels(),
42
48
  "google-generative-ai": googleProvider().getModels(),
43
49
  "openai-codex-responses": openaiCodexProvider().getModels(),
44
50
  } satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
45
51
 
46
52
  const PROVIDER_MAX_RETRIES = 2;
53
+ const MODEL_STREAM_STALL_TIMEOUT_MS = 120_000;
54
+
55
+ async function* stopStalledModelStream(
56
+ source: AsyncIterable<AssistantMessageEvent>,
57
+ watchdog: AbortController,
58
+ ): AsyncGenerator<AssistantMessageEvent> {
59
+ const iterator = source[Symbol.asyncIterator]();
60
+ while (true) {
61
+ let timer: ReturnType<typeof setTimeout> | undefined;
62
+ const next = await Promise.race([
63
+ iterator.next(),
64
+ new Promise<never>((_, reject) => {
65
+ timer = setTimeout(() => {
66
+ const error = new Error(
67
+ `Model stream stalled for ${MODEL_STREAM_STALL_TIMEOUT_MS} ms`,
68
+ );
69
+ watchdog.abort(error);
70
+ reject(error);
71
+ }, MODEL_STREAM_STALL_TIMEOUT_MS);
72
+ }),
73
+ ]).finally(() => {
74
+ if (timer !== undefined) clearTimeout(timer);
75
+ });
76
+ if (next.done) {
77
+ throw new Error("Model stream ended without a terminal event");
78
+ }
79
+ yield next.value;
80
+ if (next.value.type === "done" || next.value.type === "error") return;
81
+ }
82
+ }
47
83
 
48
84
  /**
49
- * 给模型请求补上可中断的 provider 瞬时错误重试。
85
+ * 给模型请求补上可中断的 provider 瞬时错误重试和空闲终止边界。
50
86
  *
51
87
  * Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
52
88
  *
53
- * Pi 0.83 默认 `maxRetries` 为 0,这里只补 2 次默认值,避免瞬时网络错误直接终止,同时不覆盖调用方策略。
89
+ * Pi 0.83 默认 `maxRetries` 为 0,且流无事件时会无限等待。这里补 2 次默认重试,
90
+ * 并在连续 120 秒没有模型事件时中止 provider、返回明确失败终态。
54
91
  */
55
92
  export function withProviderRetry(streamFn: StreamFn): StreamFn {
56
93
  return (model, context, options) =>
57
- streamFn(model, context, {
58
- ...options,
59
- maxRetries: options?.maxRetries ?? PROVIDER_MAX_RETRIES,
94
+ lazyStream(model, async () => {
95
+ const watchdog = new AbortController();
96
+ const source = await streamFn(model, context, {
97
+ ...options,
98
+ signal: options?.signal
99
+ ? AbortSignal.any([options.signal, watchdog.signal])
100
+ : watchdog.signal,
101
+ maxRetries: options?.maxRetries ?? PROVIDER_MAX_RETRIES,
102
+ });
103
+ return stopStalledModelStream(source, watchdog);
60
104
  });
61
105
  }
62
106
 
@@ -86,6 +130,7 @@ function providerId(endpoint: RuntimeModelEndpoint, index: number): string {
86
130
  function apiFor(protocol: RuntimeModelProtocol): Api {
87
131
  switch (protocol) {
88
132
  case "openai-chat":
133
+ case "openrouter-chat":
89
134
  return "openai-completions";
90
135
  case "anthropic-messages":
91
136
  return "anthropic-messages";
@@ -99,6 +144,7 @@ function apiFor(protocol: RuntimeModelProtocol): Api {
99
144
  function piApiFor(protocol: RuntimeModelProtocol) {
100
145
  switch (protocol) {
101
146
  case "openai-chat":
147
+ case "openrouter-chat":
102
148
  return openAICompletionsApi();
103
149
  case "anthropic-messages":
104
150
  return anthropicMessagesApi();
@@ -125,12 +171,11 @@ function configuredModel(
125
171
  `Model is not in Pi's built-in catalog for ${endpoint.protocol}: ${modelId}`,
126
172
  );
127
173
  }
128
- // 待确认:当前统一丢弃 catalogModel.compat;现有提交历史没有说明这样做对 Anthropic 兼容参数的影响,修改前需先验证 Pi 的流式请求行为。
129
174
  const {
130
175
  api: _api,
131
176
  provider: _provider,
132
177
  baseUrl: _baseUrl,
133
- compat: _compat,
178
+ compat,
134
179
  headers: catalogHeaders,
135
180
  ...metadata
136
181
  } = catalogModel;
@@ -143,6 +188,9 @@ function configuredModel(
143
188
  api: apiFor(endpoint.protocol),
144
189
  provider: providerId(endpoint, index),
145
190
  baseUrl: endpoint.baseURL,
191
+ ...(endpoint.protocol === "openrouter-chat" && compat
192
+ ? { compat }
193
+ : {}),
146
194
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
147
195
  };
148
196
  }
@@ -188,10 +236,10 @@ export function resolvePiApiKey(
188
236
  * 用当前部署端点完整重建一个已有的 Pi 模型集合。
189
237
  *
190
238
  * PiRuntimeAdapter 激活新运行快照时调用它;需要复用 MutableModels 的调用方也可以调用,并继续使用返回的同一实例。
191
- * 该函数会先清空集合,所以调用方必须把传入对象视为由 Runtime 独占管理,不能期待自定义 provider 被保留。
239
+ * 该函数在全部新 provider 校验通过后才清空并替换集合,所以失败时旧集合保持可用。
192
240
  *
193
241
  * Pi 当前用 provider.id 在 Map 中注册和路由请求,因此每个部署端点都必须同时注册唯一 provider、对应模型、认证解析器和协议实现。
194
- * clearProviders 是为了避免配置更新后旧端点继续可用;所有模型在注册时都会经过 catalog 校验,因此任一未知 modelId 会让整次配置失败。
242
+ * 替换时先构建新 provider,确保任一未知 modelId 都不会让旧端点失效;校验通过后再清空旧集合,避免配置更新后旧端点继续可用。
195
243
  *
196
244
  * @returns 传入并已重建完成的 `models` 实例。
197
245
  * @throws 当任一部署模型不在 Pi 内置 OpenAI、Anthropic catalog 中时抛出错误。
@@ -200,9 +248,8 @@ export function configurePiModels(
200
248
  models: MutableModels,
201
249
  provider: RuntimeProviderPort,
202
250
  ): MutableModels {
203
- models.clearProviders();
204
- provider.endpoints.forEach((endpoint, index) => {
205
- models.setProvider(createProvider({
251
+ const configuredProviders = provider.endpoints.map((endpoint, index) =>
252
+ createProvider({
206
253
  id: providerId(endpoint, index),
207
254
  name: endpoint.protocol,
208
255
  baseUrl: endpoint.baseURL,
@@ -219,8 +266,12 @@ export function configurePiModels(
219
266
  configuredModel(endpoint, index, modelId),
220
267
  ),
221
268
  api: piApiFor(endpoint.protocol),
222
- }));
223
- });
269
+ })
270
+ );
271
+ models.clearProviders();
272
+ configuredProviders.forEach((configuredProvider) =>
273
+ models.setProvider(configuredProvider)
274
+ );
224
275
  return models;
225
276
  }
226
277
 
@@ -66,9 +66,9 @@ function candidate<T extends TSchema>(tool: AgentTool<T>): PiToolCandidate {
66
66
  };
67
67
  }
68
68
 
69
- /** Runtime-permanent Pi tools, independent of optional Host plugins. */
69
+ /** Runtime base tools, including native web search when the model supports it. */
70
70
  export function basePiToolCandidates(
71
- webSearch: WebSearch,
71
+ webSearch?: WebSearch,
72
72
  ): PiToolCandidate[] {
73
73
  return [
74
74
  candidate({
@@ -105,6 +105,6 @@ export function basePiToolCandidates(
105
105
  });
106
106
  },
107
107
  }),
108
- webSearchPiToolCandidate(webSearch),
108
+ ...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),
109
109
  ];
110
110
  }
@@ -1106,6 +1106,8 @@ export interface WebSearchOptions {
1106
1106
 
1107
1107
  function apiFor(endpoint: RuntimeModelEndpoint): NativeApi {
1108
1108
  switch (endpoint.protocol) {
1109
+ case "openrouter-chat":
1110
+ throw new Error("Native web search is unavailable for openrouter-chat");
1109
1111
  case "google-generative-ai":
1110
1112
  return "google-generative-ai";
1111
1113
  case "openai-chat":
package/src/plugins.ts CHANGED
@@ -837,7 +837,6 @@ class RuntimeBuilder implements RuntimeContributionContext {
837
837
  denyPolicy: this.denyPolicy,
838
838
  enabledSubagents: [...this.enabledSubagents],
839
839
  mcpServers: [...this.connectors.values()],
840
- enabledExtensions: [...this.extensions.keys()],
841
840
  executionLevel: this.profile.executionLevel,
842
841
  memory: this.memoryProfile,
843
842
  });
@@ -864,12 +863,22 @@ class RuntimeBuilder implements RuntimeContributionContext {
864
863
  );
865
864
  }
866
865
  const resolvedModel = resolvePiModel(provider, profile.model);
867
- const webSearch = createWebSearch({
868
- endpoint: endpoints[0]!,
869
- model: profile.model,
870
- maxTokens: resolvedModel.maxTokens,
871
- reasoning: resolvedModel.reasoning,
872
- });
866
+ const endpoint = endpoints[0]!;
867
+ const webSearch = endpoint.protocol === "openrouter-chat"
868
+ ? undefined
869
+ : createWebSearch({
870
+ endpoint,
871
+ model: profile.model,
872
+ maxTokens: resolvedModel.maxTokens,
873
+ reasoning: resolvedModel.reasoning,
874
+ });
875
+ if (!webSearch) {
876
+ this.reportDegradation({
877
+ capability: "web_search",
878
+ reason: "unavailable",
879
+ detail: "Native web search is unavailable for openrouter-chat",
880
+ });
881
+ }
873
882
  for (const candidate of basePiToolCandidates(webSearch)) {
874
883
  this.addPiTool(candidate);
875
884
  }