@springbrand/agent-runtime 0.3.0-alpha.10 → 0.3.0-alpha.12

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.3.0-alpha.10",
3
+ "version": "0.3.0-alpha.12",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -21,8 +21,8 @@
21
21
  "@cloudflare/sandbox": "0.12.4",
22
22
  "@cloudflare/shell": "0.4.3",
23
23
  "@cloudflare/think": "0.18.0",
24
- "@earendil-works/pi-agent-core": "0.85.1",
25
- "@earendil-works/pi-ai": "0.85.1",
24
+ "@earendil-works/pi-agent-core": "0.86.1",
25
+ "@earendil-works/pi-ai": "0.86.1",
26
26
  "@types/lodash-es": "^4.17.12",
27
27
  "cron-schedule": "6.0.0",
28
28
  "isomorphic-git": "1.38.6",
@@ -67,6 +67,7 @@ import {
67
67
  import type { UIMessage, UIMessageChunk } from "ai";
68
68
  import type {
69
69
  AssistantMessage,
70
+ JsonObject,
70
71
  } from "@earendil-works/pi-ai";
71
72
  import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
72
73
  import { openCloudflareSession } from "../../session";
@@ -74,6 +75,7 @@ import { openSessionHarness, prepareSubmissionOperation } from "../../harness";
74
75
  import type { SessionHarness } from "../../harness";
75
76
  import {
76
77
  compileHarnessTools,
78
+ harnessToolActivation,
77
79
  decodeSubagentCompletion,
78
80
  HarnessDecisionGate,
79
81
  HarnessHumanDecisions,
@@ -854,6 +856,7 @@ export abstract class AgentRuntimeKernel<
854
856
  resolveReplay: gate.resolveReplay.bind(gate),
855
857
  resolveConfirmation: gate.resolveConfirmation.bind(gate),
856
858
  }),
859
+ ...harnessToolActivation(harnessInput.candidates),
857
860
  modelToolResultExemptions: harnessInput.candidates
858
861
  .filter(({ outputBudget }) => outputBudget?.kind === "structure")
859
862
  .map(({ tool }) => tool.name),
@@ -871,7 +874,7 @@ export abstract class AgentRuntimeKernel<
871
874
  part.arguments = prepareToolInput(
872
875
  part.name,
873
876
  part.arguments,
874
- ) as Record<string, unknown>;
877
+ ) as JsonObject;
875
878
  }
876
879
  }
877
880
  return { message };
@@ -5,6 +5,7 @@ import type {
5
5
  } from "@earendil-works/pi-agent-core";
6
6
  import type {
7
7
  AssistantMessage,
8
+ JsonValue,
8
9
  ToolResultMessage,
9
10
  UserMessage,
10
11
  } from "@earendil-works/pi-ai";
@@ -490,6 +491,7 @@ export class ConversationHistory {
490
491
  toolName: tool.toolName,
491
492
  ...toolResult,
492
493
  content: toolResult.content ?? [],
494
+ details: toolResult.details as JsonValue | undefined,
493
495
  isError: tool.status === "settled" && tool.isError,
494
496
  timestamp: snapshot.operation!.startedAt,
495
497
  }, { preliminary: tool.status === "running" });
@@ -56,9 +56,9 @@ export function normalizeUIUserInput(
56
56
  continue;
57
57
  }
58
58
  if (capability.kind === "tool") {
59
- if (!installed.hasTool(capability.name)) {
60
- throw new Error(`Requested Tool is not installed: ${capability.name}`);
61
- }
59
+ // if (!installed.hasTool(capability.name)) {
60
+ // throw new Error(`Requested Tool is not installed: ${capability.name}`);
61
+ // }
62
62
  context.push(
63
63
  `requested capability: tool/${capability.name}`,
64
64
  `required action: call tool "${capability.name}" when handling the task`,
@@ -4,6 +4,7 @@ import {
4
4
  type AgentHarness as PiAgentHarness,
5
5
  type AgentHarnessOptions,
6
6
  type AgentLane,
7
+ type AgentToolResult,
7
8
  type DriveOutcome,
8
9
  type DriveOptions,
9
10
  type OpenOperation,
@@ -35,6 +36,22 @@ export interface SessionHarnessOptions<TContext extends object | undefined>
35
36
  modelToolResultExemptions?: readonly string[];
36
37
  /** Base provider Session identity, retained except after a server Tool timeout. */
37
38
  modelSessionId?: string;
39
+ /**
40
+ * Compiled tools omitted from the initial active set. A later successful
41
+ * result may add them through {@link SessionHarnessOptions.toolsToActivate}.
42
+ */
43
+ onDemandToolNames?: readonly string[];
44
+ /**
45
+ * Names to add with `setActiveTools` from `after_tool`. That hook runs
46
+ * before the next generation snapshots `activeToolNames`; Pi then writes
47
+ * the difference as transcript `toolsAdded`. Do not call `setActiveTools`
48
+ * from `tool_end`: event delivery is serialized and would deadlock.
49
+ */
50
+ toolsToActivate?: (
51
+ toolName: string,
52
+ result: AgentToolResult<unknown>,
53
+ isError: boolean,
54
+ ) => readonly string[];
38
55
  }
39
56
 
40
57
  export type SessionHarnessRunResult = DriveOutcome | {
@@ -75,6 +92,10 @@ function toolCallKey(toolName: string, args: unknown): string {
75
92
  return `${toolName}\0${canonicalJson(args)}`;
76
93
  }
77
94
 
95
+ function uniqueToolNames(names: readonly string[]): string[] {
96
+ return [...new Set(names)];
97
+ }
98
+
78
99
  function projectToolResultsForModel(
79
100
  messages: readonly AgentMessage[],
80
101
  exemptions: ReadonlySet<string>,
@@ -133,14 +154,10 @@ function artifactManifestMessage(
133
154
  ];
134
155
  let omitted = 0;
135
156
  for (const ref of refs.values()) {
136
- const preview = ref.preview.replace(/[\r\n]+/g, " ").slice(0, 240);
157
+ const preview = ref.preview.replace(/[\r\n]+/g, " ").slice(0, 160);
137
158
  const line = [
138
- `artifactId=${ref.artifactId}`,
139
159
  `path=${ref.path}`,
140
- `hash=${ref.hash}`,
141
- `bytes=${ref.bytes}`,
142
- `workspaceRevision=${ref.workspaceRevision}`,
143
- `availability=${ref.availability}`,
160
+ ...(ref.availability === "unavailable" ? ["unavailable"] : []),
144
161
  `preview=${preview}`,
145
162
  ].join(" ");
146
163
  if ([...lines, line, "</workspace_artifact_manifest>"].join("\n").length > ARTIFACT_MANIFEST_MAX_CHARS) {
@@ -149,7 +166,7 @@ function artifactManifestMessage(
149
166
  }
150
167
  lines.push(line);
151
168
  }
152
- if (omitted > 0) lines.push(`omitted=${omitted} older artifact references due to manifest budget`);
169
+ if (omitted > 0) lines.push(`omitted=${omitted} additional artifact references due to manifest budget`);
153
170
  lines.push("</workspace_artifact_manifest>");
154
171
  return {
155
172
  role: "user",
@@ -192,6 +209,15 @@ export class SessionHarness<TContext extends object | undefined> {
192
209
  private readonly modelSessionId: string | undefined;
193
210
  private readonly wrapUpInstruction: string;
194
211
  private readonly configuredToolNames: string[];
212
+ private readonly compiledToolNames: ReadonlySet<string>;
213
+ private readonly onDemandToolNames: ReadonlySet<string>;
214
+ private readonly toolsToActivate:
215
+ | ((
216
+ toolName: string,
217
+ result: AgentToolResult<unknown>,
218
+ isError: boolean,
219
+ ) => readonly string[])
220
+ | undefined;
195
221
  private readonly modelToolResultExemptions: ReadonlySet<string>;
196
222
  private readonly toolFailures = new Map<string, number>();
197
223
  private totalToolFailures = 0;
@@ -236,6 +262,9 @@ export class SessionHarness<TContext extends object | undefined> {
236
262
  DEFAULT_WRAP_UP_INSTRUCTION;
237
263
  this.configuredToolNames = options.activeToolNames ??
238
264
  (options.tools ?? []).map(({ name }) => name);
265
+ this.compiledToolNames = new Set((options.tools ?? []).map(({ name }) => name));
266
+ this.onDemandToolNames = new Set(options.onDemandToolNames ?? []);
267
+ this.toolsToActivate = options.toolsToActivate;
239
268
  this.modelToolResultExemptions = new Set(
240
269
  options.modelToolResultExemptions ?? [],
241
270
  );
@@ -323,6 +352,8 @@ export class SessionHarness<TContext extends object | undefined> {
323
352
  limits: _limits,
324
353
  modelToolResultExemptions: _modelToolResultExemptions,
325
354
  modelSessionId: _modelSessionId,
355
+ onDemandToolNames: _onDemandToolNames,
356
+ toolsToActivate: _toolsToActivate,
326
357
  ...harnessOptions
327
358
  } = options;
328
359
  let runtime!: SessionHarness<TContext>;
@@ -452,6 +483,17 @@ export class SessionHarness<TContext extends object | undefined> {
452
483
  }
453
484
  await this.harness.close(eventContext);
454
485
  });
486
+ this.harness.hooks.on("after_tool", async (event, eventContext) => {
487
+ await this.activateToolsFromResult({
488
+ toolName: event.toolName,
489
+ result: {
490
+ content: event.content,
491
+ details: event.details,
492
+ },
493
+ isError: event.isError,
494
+ }, eventContext);
495
+ return undefined;
496
+ });
455
497
  this.harness.events.on("tool_start", (event) => {
456
498
  this.activeTools.add(event.toolCallId);
457
499
  });
@@ -474,6 +516,34 @@ export class SessionHarness<TContext extends object | undefined> {
474
516
  await new Promise<void>((resolve) => this.toolsIdle.add(resolve));
475
517
  }
476
518
 
519
+ private async activeToolsForNewOperation(context: Context): Promise<string[]> {
520
+ const previous = await this.lane.getActiveTools(context);
521
+ const keepOnDemand = previous.filter((name) =>
522
+ this.onDemandToolNames.has(name) && this.compiledToolNames.has(name)
523
+ );
524
+ return uniqueToolNames([...this.configuredToolNames, ...keepOnDemand]);
525
+ }
526
+
527
+ private async activateToolsFromResult(
528
+ event: {
529
+ toolName: string;
530
+ result: AgentToolResult<unknown>;
531
+ isError: boolean;
532
+ },
533
+ context: Context,
534
+ ): Promise<void> {
535
+ const requested = this.toolsToActivate?.(
536
+ event.toolName,
537
+ event.result,
538
+ event.isError,
539
+ ) ?? [];
540
+ const allowed = requested.filter((name) => this.compiledToolNames.has(name));
541
+ if (allowed.length === 0) return;
542
+ const current = await this.lane.getActiveTools(context);
543
+ if (allowed.every((name) => current.includes(name))) return;
544
+ await this.lane.setActiveTools(uniqueToolNames([...current, ...allowed]), context);
545
+ }
546
+
477
547
  private async completedModelTurns(context: Context): Promise<number> {
478
548
  return (await this.operationEntries(context)).filter((entry) =>
479
549
  entry.type === "message" && entry.message.role === "assistant"
@@ -498,7 +568,10 @@ export class SessionHarness<TContext extends object | undefined> {
498
568
  this.toolFailureWrapUp = false;
499
569
  this.toolFailureWrapUpAt = undefined;
500
570
  this.releaseReason = undefined;
501
- await this.lane.setActiveTools(this.configuredToolNames, context);
571
+ await this.lane.setActiveTools(
572
+ await this.activeToolsForNewOperation(context),
573
+ context,
574
+ );
502
575
  const admission = await this.lane.accept(request, context);
503
576
  if (!admission.ok) throw admission.error;
504
577
  operationId = admission.value.operationId;
package/src/index.ts CHANGED
@@ -94,6 +94,7 @@ export type {
94
94
  } from "./tools";
95
95
  export { basePiToolCandidates } from "./tools";
96
96
  export { createPiDeclaredToolCandidate } from "./tools";
97
+ export { harnessToolActivation } from "./tools";
97
98
  export type {
98
99
  PiDeclaredTool,
99
100
  PiDeclaredToolCallContext,
package/src/lib/prompt.ts CHANGED
@@ -118,10 +118,8 @@ export const INTERACTION =
118
118
  "without that answer; required defaults to false. At most one question may request attachments. Do not add an accept field or tell the user to upload through the normal chat composer; " +
119
119
  "continue the same turn when its result arrives. " +
120
120
  "Don't use it when a sensible default lets you proceed. " +
121
- "After completing a substantive task (report, analysis, multi-step job), finish all work and other Tool calls, " +
122
- "then call suggest_followups (2-4 directions) as the last Tool call before your final answer. " +
123
- "After it returns, write your complete final answer and make no further Tool calls. Never for small talk or while a " +
124
- "task is still in progress; it is non-blocking, unlike ask_user.";
121
+ "When a substantive task (report, analysis, or multi-step job) is complete, call suggest_followups once before the final answer. " +
122
+ "Never use it for small talk, quick answers, or while a task is still in progress; it is non-blocking, unlike ask_user.";
125
123
 
126
124
  // Separates short always-on memory from indexed long-tail workspace files.
127
125
  export const MEMORY =
@@ -2,6 +2,7 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import {
3
3
  createModels,
4
4
  createProvider,
5
+ getCurrentSystemPrompt,
5
6
  isRetryableAssistantError,
6
7
  lazyStream,
7
8
  type Api,
@@ -11,6 +12,8 @@ import {
11
12
  type Model,
12
13
  type MutableModels,
13
14
  type ProviderStreams,
15
+ type SimpleStreamOptions,
16
+ type TranscriptContext,
14
17
  } from "@earendil-works/pi-ai";
15
18
  import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
16
19
  import {
@@ -38,7 +41,7 @@ import {
38
41
  openaiCodexProvider,
39
42
  } from "@earendil-works/pi-ai/providers/openai-codex";
40
43
  import {
41
- cloudflareStreams,
44
+ resolveCloudflareModel,
42
45
  } from "@earendil-works/pi-ai/providers/cloudflare-stream";
43
46
  import {
44
47
  ChatStreamStalledError,
@@ -51,6 +54,10 @@ import type {
51
54
  } from "../config/ports";
52
55
  import { modelInputMessages } from "../conversation/message/model-input";
53
56
  import { openRouterMessagesApi } from "./openrouter/messages";
57
+ import {
58
+ overlayProviderStreams,
59
+ remapProviderStreamModels,
60
+ } from "./provider-streams";
54
61
 
55
62
  const CATALOGS = {
56
63
  "openai-chat": openaiProvider().getModels(),
@@ -130,6 +137,12 @@ export type PiGenerationLifecycleObserver = (
130
137
  event: PiGenerationLifecycleEvent,
131
138
  ) => void;
132
139
 
140
+ type ProviderBoundStream = (
141
+ model: Model<Api>,
142
+ context: Context | TranscriptContext,
143
+ options?: SimpleStreamOptions,
144
+ ) => ReturnType<StreamFn>;
145
+
133
146
  export function structuredOutputStream(
134
147
  streamFn: StreamFn,
135
148
  model: Model<Api>,
@@ -490,16 +503,19 @@ export function modelRequestUrl(model: Model<Api>): string {
490
503
  * observer 的异常被隔离,不能改变模型执行结果。
491
504
  */
492
505
  export function withProviderBoundary(
493
- streamFn: StreamFn,
506
+ streamFn: ProviderBoundStream,
494
507
  defaultSessionId?: string,
495
508
  onGeneration?: PiGenerationLifecycleObserver,
496
- ): StreamFn {
509
+ ): ProviderBoundStream {
497
510
  return (model, context, options) =>
498
511
  lazyStream(model, async () => {
499
512
  const requestId = crypto.randomUUID();
500
513
  const startedAt = Date.now();
501
514
  const sessionId = options?.sessionId ?? defaultSessionId;
502
515
  const url = modelRequestUrl(model);
516
+ const systemPrompt = "systemPrompt" in context && typeof context.systemPrompt === "string"
517
+ ? context.systemPrompt
518
+ : getCurrentSystemPrompt(context.messages);
503
519
  const notify = (event: PiGenerationLifecycleEvent) => {
504
520
  try {
505
521
  onGeneration?.(event);
@@ -516,7 +532,7 @@ export function withProviderBoundary(
516
532
  model: model.id,
517
533
  url,
518
534
  input: {
519
- ...(context.systemPrompt ? { systemPrompt: context.systemPrompt } : {}),
535
+ ...(systemPrompt ? { systemPrompt } : {}),
520
536
  messages: context.messages,
521
537
  },
522
538
  });
@@ -708,7 +724,10 @@ function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
708
724
  return openAICodexResponsesApi();
709
725
  }
710
726
  })();
711
- const contextFor = (model: Model<Api>, context: Context): Context => ({
727
+ const contextFor = (
728
+ model: Model<Api>,
729
+ context: TranscriptContext,
730
+ ): TranscriptContext => ({
712
731
  ...context,
713
732
  messages: modelInputMessages(
714
733
  context.messages,
@@ -716,12 +735,12 @@ function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
716
735
  adapterHostedInputModalities(protocol),
717
736
  ),
718
737
  });
719
- return {
738
+ return overlayProviderStreams(api, {
720
739
  stream: (model, context, options) =>
721
740
  api.stream(model, contextFor(model, context), options),
722
741
  streamSimple: (model, context, options) =>
723
742
  api.streamSimple(model, contextFor(model, context), options),
724
- };
743
+ });
725
744
  }
726
745
 
727
746
  function anthropicMessagesModelForOpenRouter(
@@ -866,9 +885,11 @@ export function configurePiModels(
866
885
  models: endpoint.models.map((modelId) =>
867
886
  configuredModel(endpoint, index, modelId),
868
887
  ),
869
- // cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
870
- // 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
871
- api: cloudflareStreams(piApiFor(endpoint)),
888
+ // remapProviderStreamModels 在运行时把 baseURL 里的
889
+ // {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID} 占位符替换成真实值;
890
+ // 如果 baseURL 不含占位符则直接透传。Pi 自带的 cloudflareStreams 只保留
891
+ // stream / streamSimple,这里 spread 其余方法以免丢掉 deferred 入口。
892
+ api: remapProviderStreamModels(piApiFor(endpoint), resolveCloudflareModel),
872
893
  })
873
894
  );
874
895
  models.clearProviders();
@@ -1,12 +1,14 @@
1
1
  import type {
2
- Context,
3
2
  Model,
4
3
  ProviderStreams,
5
4
  StreamOptions,
5
+ TranscriptContext,
6
6
  } from "@earendil-works/pi-ai";
7
+ import { getCurrentTools } from "@earendil-works/pi-ai";
7
8
  import {
8
9
  anthropicMessagesApi,
9
10
  } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
11
+ import { overlayProviderStreams } from "../provider-streams";
10
12
 
11
13
  type Payload = Record<string, unknown>;
12
14
 
@@ -21,14 +23,16 @@ function record(value: unknown): Payload | undefined {
21
23
  function decoratePayload(
22
24
  payload: unknown,
23
25
  model: Model<"anthropic-messages">,
24
- context: Context,
26
+ context: TranscriptContext,
25
27
  sessionId?: string,
26
28
  ): unknown {
27
29
  const body = record(payload);
28
30
  if (!body) throw new Error("OpenRouter Messages payload must be an object");
29
31
 
30
32
  const deferredNames = new Set(
31
- context.tools?.filter((tool) => tool.deferLoading).map((tool) => tool.name),
33
+ getCurrentTools(context.messages)
34
+ .filter((tool) => tool.deferLoading)
35
+ .map((tool) => tool.name),
32
36
  );
33
37
  const tools = Array.isArray(body.tools) ? body.tools : [];
34
38
  const immediate: unknown[] = [];
@@ -80,7 +84,7 @@ function decoratePayload(
80
84
 
81
85
  function openRouterOptions(
82
86
  model: Model<"anthropic-messages">,
83
- context: Context,
87
+ context: TranscriptContext,
84
88
  options: StreamOptions | undefined,
85
89
  ): StreamOptions {
86
90
  const apiKey = options?.apiKey;
@@ -107,9 +111,10 @@ function openRouterOptions(
107
111
  };
108
112
  }
109
113
 
110
- export function openRouterMessagesApi(): ProviderStreams {
111
- const anthropic = anthropicMessagesApi();
112
- return {
114
+ export function openRouterMessagesApi(
115
+ anthropic: ProviderStreams = anthropicMessagesApi(),
116
+ ): ProviderStreams {
117
+ return overlayProviderStreams(anthropic, {
113
118
  stream: (model, context, options) =>
114
119
  anthropic.stream(
115
120
  model,
@@ -130,5 +135,5 @@ export function openRouterMessagesApi(): ProviderStreams {
130
135
  options,
131
136
  ),
132
137
  ),
133
- };
138
+ });
134
139
  }
@@ -0,0 +1,45 @@
1
+ import type {
2
+ Api,
3
+ Model,
4
+ ProviderEnv,
5
+ ProviderStreams,
6
+ } from "@earendil-works/pi-ai";
7
+
8
+ /**
9
+ * ProviderStreams 是普通对象,不是 class。完整适配先 spread 底层全部方法,再覆盖需要装饰的入口。
10
+ * 只手写 stream / streamSimple 会丢掉 Pi 后续挂上的 fetchDeferred / cancelDeferred。
11
+ */
12
+ export function overlayProviderStreams(
13
+ base: ProviderStreams,
14
+ overlay: Partial<ProviderStreams>,
15
+ ): ProviderStreams {
16
+ return { ...base, ...overlay };
17
+ }
18
+
19
+ /**
20
+ * 在保留其余方法的前提下,把每个已知的 model 入口改写到 remap 后的模型。
21
+ * 未知的未来方法仍通过 spread 透传,避免再出现白名单丢方法。
22
+ */
23
+ export function remapProviderStreamModels(
24
+ streams: ProviderStreams,
25
+ remap: (model: Model<Api>, env: ProviderEnv | undefined) => Model<Api>,
26
+ ): ProviderStreams {
27
+ return overlayProviderStreams(streams, {
28
+ stream: (model, context, options) =>
29
+ streams.stream(remap(model, options?.env), context, options),
30
+ streamSimple: (model, context, options) =>
31
+ streams.streamSimple(remap(model, options?.env), context, options),
32
+ ...(streams.fetchDeferred
33
+ ? {
34
+ fetchDeferred: (model, handle, options) =>
35
+ streams.fetchDeferred!(remap(model, options?.env), handle, options),
36
+ }
37
+ : {}),
38
+ ...(streams.cancelDeferred
39
+ ? {
40
+ cancelDeferred: (model, handle, options) =>
41
+ streams.cancelDeferred!(remap(model, options?.env), handle, options),
42
+ }
43
+ : {}),
44
+ });
45
+ }
@@ -436,7 +436,6 @@ function assertSameToolResult(
436
436
  "content",
437
437
  "details",
438
438
  "usage",
439
- "addedToolNames",
440
439
  "isError",
441
440
  ] as const) {
442
441
  if (!isEqual(left[key], right[key]))
@@ -456,11 +455,8 @@ function legacyToolResultMessage(input: {
456
455
  toolCallId: input.toolCallId,
457
456
  toolName: input.toolName,
458
457
  content: input.result.content ?? [],
459
- details: input.result.details,
458
+ details: input.result.details as JsonValue | undefined,
460
459
  ...(input.result.usage ? { usage: input.result.usage } : {}),
461
- ...(input.result.addedToolNames?.length
462
- ? { addedToolNames: input.result.addedToolNames }
463
- : {}),
464
460
  isError: input.isError,
465
461
  timestamp: input.timestamp,
466
462
  };
@@ -1,5 +1,5 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
- import { validateToolArguments } from "@earendil-works/pi-ai";
2
+ import { validateToolArguments, type JsonObject } from "@earendil-works/pi-ai";
3
3
  import { tool as aiTool, jsonSchema, type ToolSet } from "ai";
4
4
  import type { PiToolCandidate } from "../runtime/contracts";
5
5
 
@@ -27,7 +27,7 @@ export function piCandidatesToAiTools(
27
27
  type: "toolCall",
28
28
  id: toolCallId,
29
29
  name: candidate.tool.name,
30
- arguments: prepared as Record<string, unknown>,
30
+ arguments: prepared as JsonObject,
31
31
  });
32
32
  const result = await candidate.tool.execute(
33
33
  toolCallId,
@@ -2,7 +2,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { type TSchema } from "@earendil-works/pi-ai";
3
3
  import type { PiToolCandidate } from "../runtime/contracts";
4
4
 
5
- export function candidate<T extends TSchema>(tool: AgentTool<T>): PiToolCandidate {
5
+ export function candidate<T extends TSchema, D = any>(tool: AgentTool<T, D>): PiToolCandidate<T, D> {
6
6
  return {
7
7
  owner: "runtime-base",
8
8
  requiredExecutionLevel: "safe",
@@ -10,6 +10,6 @@ export function candidate<T extends TSchema>(tool: AgentTool<T>): PiToolCandidat
10
10
  tool: {
11
11
  ...tool,
12
12
  constrainedSampling: { type: "json_schema", strict: "prefer" },
13
- } as AgentTool<T>,
13
+ } as AgentTool<T, D>,
14
14
  };
15
15
  }
@@ -19,7 +19,7 @@ export function suggestFollowupsPiToolCandidate(): PiToolCandidate {
19
19
  name: "suggest_followups",
20
20
  label: "Suggest follow-ups",
21
21
  description:
22
- "Offer the user 2-4 optional follow-up directions after completing a substantive task (a report, an analysis, a multi-step job). Call this at most once, after all work and other Tool calls are complete. It MUST be the last Tool call before the final answer. After it returns, write the complete final answer and make no further Tool calls. Do NOT call it for small talk, quick answers, or while a task is still in progress.",
22
+ "Offer the user 2-4 optional follow-up directions for a completed substantive task. Call at most once.",
23
23
  parameters: suggestFollowupsParameters,
24
24
  async execute(_toolCallId, input) {
25
25
  return result({ noted: true, count: input.items.length });
@@ -164,13 +164,9 @@ function codeExecutionResult(
164
164
  details: unknown,
165
165
  innerResults: readonly AgentToolResult<unknown>[],
166
166
  ): AgentToolResult<unknown> {
167
- const addedToolNames = [...new Set(
168
- innerResults.flatMap(({ addedToolNames }) => addedToolNames ?? []),
169
- )].sort();
170
167
  const usage = sumUsage(innerResults);
171
168
  return {
172
169
  ...result(details),
173
- ...(addedToolNames.length > 0 ? { addedToolNames } : {}),
174
170
  ...(usage ? { usage } : {}),
175
171
  ...(innerResults.length > 0 && innerResults.every(({ terminate }) => terminate === true)
176
172
  ? { terminate: true }
@@ -3,6 +3,7 @@ export * from "./runtime/approval";
3
3
  export * from "./assistant";
4
4
  export * from "./runtime/contracts";
5
5
  export * from "./runtime/compiler";
6
+ export * from "./runtime/activation";
6
7
  export * from "./codemode";
7
8
  export * from "./codemode/adapter";
8
9
  export * from "./extension";
@@ -0,0 +1,40 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { PiToolCandidate } from "./contracts";
3
+
4
+ /**
5
+ * 把 Candidate 上的按需激活声明收成 SessionHarness 可直接消费的选项。
6
+ *
7
+ * `inactiveUntilActivated` 的工具仍编译进 Harness,只是一开始不在活动集里。
8
+ * 其他工具成功返回后通过 `toolsToActivate` 点名,Harness 在 `after_tool`
9
+ * 里 `setActiveTools`,由 Pi Agent Loop 写成 transcript 的 `toolsAdded`。
10
+ */
11
+ export function harnessToolActivation(candidates: readonly PiToolCandidate[]): {
12
+ readonly activeToolNames: string[];
13
+ readonly onDemandToolNames: readonly string[];
14
+ readonly toolsToActivate: (
15
+ toolName: string,
16
+ result: AgentToolResult<unknown>,
17
+ isError: boolean,
18
+ ) => readonly string[];
19
+ } {
20
+ const compiled = candidates.map(({ tool }) => tool.name);
21
+ const onDemand = candidates
22
+ .filter((candidate) => candidate.inactiveUntilActivated === true)
23
+ .map(({ tool }) => tool.name);
24
+ const onDemandSet = new Set(onDemand);
25
+ const activators = new Map(
26
+ candidates.flatMap((candidate) =>
27
+ candidate.toolsToActivate
28
+ ? [[candidate.tool.name, candidate.toolsToActivate] as const]
29
+ : [],
30
+ ),
31
+ );
32
+ return {
33
+ activeToolNames: compiled.filter((name) => !onDemandSet.has(name)),
34
+ onDemandToolNames: onDemand,
35
+ toolsToActivate: (toolName, result, isError) => {
36
+ if (isError) return [];
37
+ return activators.get(toolName)?.(result) ?? [];
38
+ },
39
+ };
40
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { TSchema } from "@earendil-works/pi-ai";
2
3
  import type { ApprovalReceipt } from "../../submission/contracts";
3
4
  import { type BudgetPolicy } from "../../context/memory/budget/gate";
4
5
  import { type SpillWorkspace } from "../../lib/artifacts";
@@ -45,12 +46,22 @@ export interface PiToolConfirmationSpec {
45
46
  execute(prepared: unknown, context: { intentId: string; signal: AbortSignal }): Promise<AgentToolResult<unknown>>;
46
47
  }
47
48
 
48
- export interface PiToolCandidate {
49
+ /**
50
+ * 一份尚未进入最终 Tool Surface 的 Pi 工具及其治理字段。
51
+ *
52
+ * 单个工厂可以写成 `PiToolCandidate<typeof parameters, Details>`,把 Schema 和
53
+ * details 留在类型里。混进同一个 `PiToolCandidate[]` 时走默认擦除形式:
54
+ * `AgentTool` 在两个类型参数上都是不变的,只有默认 `any` 能收进异构列表。
55
+ */
56
+ export interface PiToolCandidate<
57
+ TParameters extends TSchema = any,
58
+ TDetails = any,
59
+ > {
49
60
  /** Presentation only, resolved at the model boundary; never changes approved execution inputs. */
50
61
  readonly modelGuidance?: () => Promise<{ description: string; systemPrompt?: string }>;
51
62
  readonly confirmation?: PiToolConfirmationSpec;
52
63
  readonly owner: string;
53
- readonly tool: AgentTool<any, any>;
64
+ readonly tool: AgentTool<TParameters, TDetails>;
54
65
  /** @internal Send the complete schema but hide it until provider Tool Search finds it. */
55
66
  readonly deferLoading?: true;
56
67
  /** Omitted means visible both directly and through Code Mode. */
@@ -80,6 +91,18 @@ export interface PiToolCandidate {
80
91
  readonly interaction?: PiToolInteractionSpec;
81
92
  /** Large payloads spill by default; protocol-shaped results opt into structure. */
82
93
  readonly outputBudget?: BudgetPolicy;
94
+ /**
95
+ * 编进 Harness 工具表,但不进入初始 `setActiveTools`。
96
+ * 之后由其他 Tool 的 `toolsToActivate` 经 `setActiveTools` 写入 transcript `toolsAdded`。
97
+ */
98
+ readonly inactiveUntilActivated?: true;
99
+ /**
100
+ * 本次成功结果要把哪些已编译工具加入活动集。未知名字会被忽略。
101
+ * 替代已删除的 `AgentToolResult.addedToolNames`。
102
+ */
103
+ readonly toolsToActivate?: (
104
+ result: AgentToolResult<unknown>,
105
+ ) => readonly string[];
83
106
  }
84
107
 
85
108
  export interface CompileHarnessToolsOptions {
@@ -1,5 +1,5 @@
1
1
  import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
2
- import { Type, fauxToolCall, validateToolArguments, type TSchema } from "@earendil-works/pi-ai";
2
+ import { Type, fauxToolCall, validateToolArguments, type JsonObject, type TSchema } from "@earendil-works/pi-ai";
3
3
  import {
4
4
  toScheduleRuleSpec,
5
5
  validateScheduleRuleSpec,
@@ -261,7 +261,7 @@ export function schedulePiToolCandidates(
261
261
  return [{
262
262
  ...toolCandidate, alwaysRequiresApproval: true, confirmation: {
263
263
  async prepare(args, { intentId }) {
264
- const validated = validateToolArguments(toolCandidate.tool, fauxToolCall(name, args as Record<string, unknown>));
264
+ const validated = validateToolArguments(toolCandidate.tool, fauxToolCall(name, args as JsonObject));
265
265
  const input = validated as { id?: string; prompt?: string; trigger?: ScheduleSpec["trigger"]; label?: string; timezone?: string };
266
266
  if (input.timezone !== undefined && !isValidTimeZone(input.timezone)) throw new Error("A valid IANA timezone is required");
267
267
  if (input.trigger?.kind === "local" && input.timezone) {