@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 +3 -3
- package/src/adapter/cloudflare/agent.ts +4 -1
- package/src/conversation/history.ts +2 -0
- package/src/conversation/message/conversion.ts +3 -3
- package/src/harness/session-harness.ts +81 -8
- package/src/index.ts +1 -0
- package/src/lib/prompt.ts +2 -4
- package/src/models/index.ts +31 -10
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +1 -5
- package/src/tools/adapters/pi-to-ai.ts +2 -2
- package/src/tools/assistant/candidate.ts +2 -2
- package/src/tools/assistant/followups.ts +1 -1
- package/src/tools/codemode/adapter.ts +0 -4
- package/src/tools/index.ts +1 -0
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/runtime/contracts.ts +25 -2
- package/src/tools/schedule/index.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/agent-runtime",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
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.
|
|
25
|
-
"@earendil-works/pi-ai": "0.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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}
|
|
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(
|
|
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
|
-
"
|
|
122
|
-
"
|
|
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 =
|
package/src/models/index.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
506
|
+
streamFn: ProviderBoundStream,
|
|
494
507
|
defaultSessionId?: string,
|
|
495
508
|
onGeneration?: PiGenerationLifecycleObserver,
|
|
496
|
-
):
|
|
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
|
-
...(
|
|
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 = (
|
|
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
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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(
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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 }
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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
|
|
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) {
|