mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
[3/5] feat(eve): add tool lifecycle labels (#2856)
Signed-off-by: benpankow <ben.pankow@vercel.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"eve": patch
|
||||
---
|
||||
|
||||
Allow async-generator tools to project typed preliminary and final results into bounded user-facing activity text with `activity.update` and `activity.result`.
|
||||
+20
-2
@@ -66,10 +66,15 @@ yield is the normal tool result the model receives:
|
||||
export default defineTool({
|
||||
description: "Build a project report.",
|
||||
inputSchema: z.object({ project: z.string() }),
|
||||
label: {
|
||||
start: ({ project }) => `Build report for ${project}`,
|
||||
delta: (_input, partial) => partial.phase,
|
||||
complete: (_input, output) => `Report ready with ${output.report.sections.length} sections`,
|
||||
},
|
||||
async *execute({ project }) {
|
||||
yield { phase: "collecting", report: null };
|
||||
yield { phase: "Collecting sources", report: null };
|
||||
const report = await buildReport(project);
|
||||
yield { phase: "complete", report };
|
||||
yield { phase: "Complete", report };
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -82,6 +87,19 @@ can retry a step and replay overlapping snapshots. A generator `return` value is
|
||||
the result in this mode; yield the final output. Workflow and background generators have
|
||||
different result rules, described under [yield and return](#yield-and-return).
|
||||
|
||||
Use `label.delta(input, partial)` to project preliminary snapshots into
|
||||
user-facing activity, and `label.complete(input, output)` to describe successful
|
||||
settlement differently. Both callbacks receive the validated input first and the typed value yielded by
|
||||
`execute` second. Each non-empty update replaces the previous activity label; the
|
||||
result projection replaces the latest update immediately before eve marks the
|
||||
action complete.
|
||||
|
||||
eve normalizes and bounds projected text before rendering it. If either
|
||||
callback throws or returns an empty string, eve keeps the existing activity
|
||||
label. `label.complete` does not run for failed or rejected tool calls. The
|
||||
full values remain available in `action.partial` and `action.result`; renderers
|
||||
receive only the projected text.
|
||||
|
||||
### Background execution
|
||||
|
||||
Background execution controls how a tool delivers its result to the parent agent. Durable
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { defineDynamic, defineTool } from "#public/tools/index.js";
|
||||
|
||||
export default defineDynamic({
|
||||
events: {
|
||||
"session.started": () =>
|
||||
defineTool({
|
||||
description: "Delegate a background report.",
|
||||
execution: "background",
|
||||
inputSchema: z.object({ reportId: z.string() }),
|
||||
async *execute({ reportId }) {
|
||||
yield { reportId };
|
||||
return { reportId };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { defineTool, defineWorkflowTool } from "#public/tools/index.js";
|
||||
|
||||
export const write = defineTool({
|
||||
description: "Write an approved message.",
|
||||
inputSchema: z.object({ message: z.string() }),
|
||||
outputSchema: z.object({ written: z.string() }),
|
||||
approval: ({ toolInput }) => (toolInput?.message ? "user-approval" : "not-applicable"),
|
||||
execute: (input) => ({ written: input.message }),
|
||||
toModelOutput: (output) => ({ type: "text", value: output.written }),
|
||||
});
|
||||
|
||||
export const workflow = defineWorkflowTool({
|
||||
description: "Run a report workflow.",
|
||||
inputSchema: z.object({ report: z.string() }),
|
||||
async execute(input) {
|
||||
"use workflow";
|
||||
return { report: input.report };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"kind": "eve-extension-capability-contract",
|
||||
"capability": "dynamicTool",
|
||||
"epoch": 31,
|
||||
"sha256": "9a11565402d4eaa6a5acd56900f9e247a8cd549f7c4a8afc80a609615b2c6c2e",
|
||||
"exports": [
|
||||
"DynamicToolEntry",
|
||||
"DynamicToolEvents",
|
||||
"DynamicToolResult",
|
||||
"DynamicToolSet",
|
||||
"defineDynamic"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"kind": "eve-extension-capability-contract",
|
||||
"capability": "tool",
|
||||
"epoch": 32,
|
||||
"sha256": "deee6ca5bc3e96d6f0f080d703c1d69381cafa43263f64625d9b37de1ce3b28e",
|
||||
"exports": [
|
||||
"defaultWebSearch",
|
||||
"defineTool",
|
||||
"defineWorkflowTool",
|
||||
"disableTool",
|
||||
"experimental_workflow",
|
||||
"isDisabledToolSentinel",
|
||||
"isExperimentalWorkflowToolDefinition",
|
||||
"isWebSearchToolDefinition",
|
||||
"toolOutput",
|
||||
"toolOutputPart",
|
||||
"toolResultFrom",
|
||||
"webSearch"
|
||||
]
|
||||
}
|
||||
@@ -22,8 +22,8 @@ interface ExtensionCapabilityContract {
|
||||
const EXTENSION_CAPABILITY_CONTRACTS = {
|
||||
extension: { current: 1, supported: [1], dropped: {} },
|
||||
tool: {
|
||||
current: 31,
|
||||
supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31],
|
||||
current: 32,
|
||||
supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32],
|
||||
dropped: {
|
||||
14: "TaskExec.delegated was removed; migrate to workflow-backed background tools",
|
||||
15: "TaskExec replaces stageEffect with send",
|
||||
@@ -42,9 +42,9 @@ const EXTENSION_CAPABILITY_CONTRACTS = {
|
||||
},
|
||||
},
|
||||
dynamicTool: {
|
||||
current: 30,
|
||||
current: 31,
|
||||
supported: [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 28, 29, 30,
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 28, 29, 30, 31,
|
||||
],
|
||||
dropped: {
|
||||
21: "Message and reasoning append events now expose deltas instead of cumulative snapshots.",
|
||||
|
||||
@@ -105,6 +105,13 @@ export function replayDynamicTools(
|
||||
: lookupDurableDynamicCallback(owner, "approvalKey");
|
||||
const executeReference = entry.callbacks.execute;
|
||||
const execute = lookupDurableDynamicCallback(owner, "execute");
|
||||
const labelComplete = bindDynamicCallback(
|
||||
entry,
|
||||
owner,
|
||||
"labelComplete",
|
||||
entry.callbacks.label?.complete,
|
||||
);
|
||||
const labelDelta = bindDynamicCallback(entry, owner, "labelDelta", entry.callbacks.label?.delta);
|
||||
const labelStart = bindDynamicCallback(entry, owner, "labelStart", entry.callbacks.label?.start);
|
||||
const toModelOutput = bindDynamicCallback(
|
||||
entry,
|
||||
@@ -175,8 +182,12 @@ export function replayDynamicTools(
|
||||
}),
|
||||
outputSchema: toOutputSchema(entry.outputSchema),
|
||||
};
|
||||
if (labelStart !== undefined) {
|
||||
replayed.label = { start: (input: unknown) => labelStart(input) as string };
|
||||
if (labelComplete !== undefined || labelDelta !== undefined || labelStart !== undefined) {
|
||||
replayed.label = {
|
||||
complete: labelComplete,
|
||||
delta: labelDelta,
|
||||
start: labelStart,
|
||||
};
|
||||
}
|
||||
if (toModelOutput !== undefined) replayed.toModelOutput = toModelOutput;
|
||||
return replayed;
|
||||
@@ -188,7 +199,7 @@ function bindDynamicCallback(
|
||||
owner: DynamicToolCallbackOwner,
|
||||
phase: DurableDynamicCallbackPhase,
|
||||
reference: DurableDynamicCallbackReference | undefined,
|
||||
): ((...args: unknown[]) => unknown) | undefined {
|
||||
): ((...args: unknown[]) => any) | undefined {
|
||||
if (reference === undefined) return undefined;
|
||||
const callback = lookupDurableDynamicCallback(owner, phase);
|
||||
return (...args) => {
|
||||
|
||||
@@ -1536,7 +1536,7 @@ describe("programmatic dynamic tools (no bundler transform)", () => {
|
||||
expect(approvalFn).toHaveBeenCalledExactlyOnceWith(approvalCtx);
|
||||
});
|
||||
|
||||
it("replays a label start callback", () => {
|
||||
it("replays label callbacks", () => {
|
||||
const ctx = createCtx();
|
||||
const owner = {
|
||||
sessionId: ctx.require(SessionIdKey),
|
||||
@@ -1551,10 +1551,26 @@ describe("programmatic dynamic tools (no bundler transform)", () => {
|
||||
(_closure, input) => `Deploy to ${String((input as { environment: unknown }).environment)}`,
|
||||
owner,
|
||||
);
|
||||
registerTestCallback(
|
||||
"deploy",
|
||||
"labelComplete",
|
||||
(_closure, _input, output) => `Deployed to ${String((output as { url: unknown }).url)}`,
|
||||
owner,
|
||||
);
|
||||
registerTestCallback(
|
||||
"deploy",
|
||||
"labelDelta",
|
||||
(_closure, _input, partial) => String((partial as { phase: unknown }).phase),
|
||||
owner,
|
||||
);
|
||||
ctx.set(TurnDynamicToolMetadataKey, [
|
||||
{
|
||||
callbacks: {
|
||||
label: { start: { closure: {} } },
|
||||
label: {
|
||||
complete: { closure: {} },
|
||||
delta: { closure: {} },
|
||||
start: { closure: {} },
|
||||
},
|
||||
execute: { closure: {} },
|
||||
},
|
||||
description: "Deploy.",
|
||||
@@ -1565,8 +1581,13 @@ describe("programmatic dynamic tools (no bundler transform)", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildDynamicTools(ctx)[0]?.label?.start?.({ environment: "preview" })).toBe(
|
||||
"Deploy to preview",
|
||||
const tool = buildDynamicTools(ctx)[0];
|
||||
expect(tool?.label?.start?.({ environment: "preview" })).toBe("Deploy to preview");
|
||||
expect(
|
||||
tool?.label?.complete?.({ environment: "preview" }, { url: "preview.example.com" }),
|
||||
).toBe("Deployed to preview.example.com");
|
||||
expect(tool?.label?.delta?.({ environment: "preview" }, { phase: "Uploading" })).toBe(
|
||||
"Uploading",
|
||||
);
|
||||
clearDurableDynamicCallbacks(owner.sessionId);
|
||||
});
|
||||
|
||||
@@ -199,6 +199,8 @@ export function validateDurableDynamicToolCallbacks(
|
||||
);
|
||||
}
|
||||
|
||||
const hasLabelComplete = entry.label?.complete !== undefined;
|
||||
const hasLabelDelta = entry.label?.delta !== undefined;
|
||||
const hasLabelStart = entry.label?.start !== undefined;
|
||||
const hasApproval = entry.approval !== undefined;
|
||||
const hasApprovalResponse =
|
||||
@@ -212,6 +214,20 @@ export function validateDurableDynamicToolCallbacks(
|
||||
stamped: raw.execute,
|
||||
required: true,
|
||||
})!;
|
||||
const labelComplete = validateReference({
|
||||
name,
|
||||
owner,
|
||||
phase: "labelComplete",
|
||||
stamped: raw.label?.complete,
|
||||
required: hasLabelComplete,
|
||||
});
|
||||
const labelDelta = validateReference({
|
||||
name,
|
||||
owner,
|
||||
phase: "labelDelta",
|
||||
stamped: raw.label?.delta,
|
||||
required: hasLabelDelta,
|
||||
});
|
||||
const labelStart = validateReference({
|
||||
name,
|
||||
owner,
|
||||
@@ -250,13 +266,23 @@ export function validateDurableDynamicToolCallbacks(
|
||||
|
||||
const callbacks: {
|
||||
execute: DurableDynamicCallbackReference;
|
||||
label?: { start?: DurableDynamicCallbackReference };
|
||||
label?: {
|
||||
complete?: DurableDynamicCallbackReference;
|
||||
delta?: DurableDynamicCallbackReference;
|
||||
start?: DurableDynamicCallbackReference;
|
||||
};
|
||||
approvalKey?: DurableDynamicCallbackReference;
|
||||
approvalRequest?: DurableDynamicCallbackReference;
|
||||
approvalResponse?: DurableDynamicCallbackReference;
|
||||
toModelOutput?: DurableDynamicCallbackReference;
|
||||
} = { execute };
|
||||
if (labelStart !== undefined) callbacks.label = { start: labelStart };
|
||||
if (labelComplete !== undefined || labelDelta !== undefined || labelStart !== undefined) {
|
||||
callbacks.label = {
|
||||
complete: labelComplete,
|
||||
delta: labelDelta,
|
||||
start: labelStart,
|
||||
};
|
||||
}
|
||||
if (approvalKey !== undefined) callbacks.approvalKey = approvalKey;
|
||||
if (approvalRequest !== undefined) callbacks.approvalRequest = approvalRequest;
|
||||
if (approvalResponse !== undefined) callbacks.approvalResponse = approvalResponse;
|
||||
|
||||
@@ -115,7 +115,79 @@ describe("projectActivityEvents", () => {
|
||||
expect(event.blocker.label).toHaveLength(MAX_ACTIVITY_TEXT_LENGTH);
|
||||
});
|
||||
|
||||
it("projects safe tool settlement", () => {
|
||||
it("projects safe tool updates from partial events", () => {
|
||||
expect(
|
||||
projectActivityEvents({
|
||||
at: "2026-01-01T00:00:01Z",
|
||||
event: {
|
||||
data: {
|
||||
presentation: { "tool-1": { label: "Collecting sources" } },
|
||||
result: {
|
||||
callId: "tool-1",
|
||||
kind: "tool-result",
|
||||
output: { secret: "hidden" },
|
||||
toolName: "search",
|
||||
},
|
||||
sequence: 0,
|
||||
stepIndex: 0,
|
||||
turnId: "turn",
|
||||
},
|
||||
type: "action.partial",
|
||||
},
|
||||
eventId: "partial-1",
|
||||
lineage,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
actionId: "action:work:root:turn:tool-1",
|
||||
eventId: "action:work:root:turn:tool-1:update:partial-1",
|
||||
kind: "action.label.updated",
|
||||
label: "Collecting sources",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects successful result text before tool settlement", () => {
|
||||
expect(
|
||||
projectActivityEvents({
|
||||
at: "2026-01-01T00:00:02Z",
|
||||
event: {
|
||||
data: {
|
||||
presentation: { "tool-1": { label: "Report ready" } },
|
||||
result: {
|
||||
callId: "tool-1",
|
||||
kind: "tool-result",
|
||||
output: { report: "hidden" },
|
||||
toolName: "build_report",
|
||||
},
|
||||
sequence: 0,
|
||||
status: "completed",
|
||||
stepIndex: 0,
|
||||
turnId: "turn",
|
||||
},
|
||||
type: "action.result",
|
||||
},
|
||||
eventId: "result-1",
|
||||
lineage,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
actionId: "action:work:root:turn:tool-1",
|
||||
eventId: "action:work:root:turn:tool-1:result:result-1",
|
||||
kind: "action.label.updated",
|
||||
label: "Report ready",
|
||||
},
|
||||
{
|
||||
actionId: "action:work:root:turn:tool-1",
|
||||
eventId: "action:work:root:turn:tool-1:settled:completed",
|
||||
kind: "action.settled",
|
||||
outcome: "completed",
|
||||
settledAt: "2026-01-01T00:00:02Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects safe failed tool settlement", () => {
|
||||
expect(
|
||||
projectActivityEvents({
|
||||
at: "2026-01-01T00:00:01Z",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UnstampedMessageStreamEvent } from "#protocol/message.js";
|
||||
export function projectActivityEvents(input: {
|
||||
readonly at: string;
|
||||
readonly event: UnstampedMessageStreamEvent;
|
||||
readonly eventId?: string;
|
||||
readonly lineage: ActivityWorkIdentityV1;
|
||||
}): readonly ActivityEventV1[] {
|
||||
const { event, lineage } = input;
|
||||
@@ -44,6 +45,20 @@ export function projectActivityEvents(input: {
|
||||
];
|
||||
});
|
||||
}
|
||||
if (event.type === "action.partial") {
|
||||
const id = actionId(lineage.id, event.data.result.callId);
|
||||
const label = activityLabel(event.data.presentation?.[event.data.result.callId]?.label);
|
||||
return label === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
actionId: id,
|
||||
eventId: `${id}:update:${input.eventId ?? input.at}`,
|
||||
kind: "action.label.updated",
|
||||
label,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (event.type === "action.result") {
|
||||
const result = event.data.result;
|
||||
if (result.kind === "subagent-result") {
|
||||
@@ -72,7 +87,18 @@ export function projectActivityEvents(input: {
|
||||
];
|
||||
}
|
||||
const id = actionId(lineage.id, result.callId);
|
||||
const label = activityLabel(event.data.presentation?.[result.callId]?.label);
|
||||
return [
|
||||
...(label === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
actionId: id,
|
||||
eventId: `${id}:result:${input.eventId ?? input.at}`,
|
||||
kind: "action.label.updated" as const,
|
||||
label,
|
||||
},
|
||||
]),
|
||||
{
|
||||
actionId: id,
|
||||
eventId: `${id}:settled:${event.data.status}`,
|
||||
|
||||
@@ -86,6 +86,38 @@ describe("projectSessionActivity", () => {
|
||||
expect(snapshot.pendingSettlements).toEqual({});
|
||||
});
|
||||
|
||||
it("uses the durable partial event id for activity updates", () => {
|
||||
const event: MessageStreamEvent = {
|
||||
data: {
|
||||
presentation: { "tool-1": { label: "Collecting sources" } },
|
||||
result: {
|
||||
callId: "tool-1",
|
||||
kind: "tool-result",
|
||||
output: { phase: "Collecting" },
|
||||
toolName: "build_report",
|
||||
},
|
||||
sequence: 0,
|
||||
stepIndex: 0,
|
||||
turnId: "turn-1",
|
||||
},
|
||||
meta: { at, id: "partial-1" },
|
||||
type: "action.partial",
|
||||
};
|
||||
|
||||
expect(
|
||||
projectSessionActivity({
|
||||
event,
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
eventId: expect.stringContaining(":update:partial-1"),
|
||||
kind: "action.label.updated",
|
||||
label: "Collecting sources",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps session and later turn starts to the active delegated work", () => {
|
||||
const first: ActivityWorkIdentityV1 = {
|
||||
callId: "call-1",
|
||||
|
||||
@@ -55,6 +55,7 @@ export function projectSessionActivity(input: {
|
||||
...projectActivityEvents({
|
||||
at: input.event.meta.at,
|
||||
event: input.event,
|
||||
eventId: input.event.meta.id,
|
||||
lineage: work,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -52,13 +52,19 @@ describe("activity protocol and reducer", () => {
|
||||
kind: "action.label.updated",
|
||||
label: "Search issues",
|
||||
},
|
||||
{
|
||||
actionId: "action",
|
||||
eventId: "action:update",
|
||||
kind: "action.label.updated",
|
||||
label: "Found 3 issues",
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
});
|
||||
|
||||
expect(batch).toBeDefined();
|
||||
expect(reduceActivityBatch(createActivitySnapshot(), batch!).actions.action?.label).toBe(
|
||||
"Search issues",
|
||||
"Found 3 issues",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -583,6 +583,12 @@ describe("emitStreamContent action requests", () => {
|
||||
[
|
||||
"web_search",
|
||||
{
|
||||
label: {
|
||||
complete: (_input, output) =>
|
||||
`Found ${(output as { results: unknown[] }).results.length}\u0000 final results`,
|
||||
delta: (_input, partial) =>
|
||||
`Found ${(partial as { results: unknown[] }).results.length}\u0000 results`,
|
||||
},
|
||||
description: "Search the web.",
|
||||
execute: async () => ({ results: [] }),
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
@@ -650,10 +656,16 @@ describe("emitStreamContent action requests", () => {
|
||||
data: { result: { output: { results: ["partial"] } } },
|
||||
type: "action.partial",
|
||||
});
|
||||
expect(localEvents[3]).toMatchObject({
|
||||
data: { presentation: { "call-1": { label: "Found 1 results" } } },
|
||||
});
|
||||
expect(localEvents[4]).toMatchObject({
|
||||
data: { result: { output: { results: ["eve"] } } },
|
||||
type: "action.result",
|
||||
});
|
||||
expect(localEvents[4]).toMatchObject({
|
||||
data: { presentation: { "call-1": { label: "Found 1 final results" } } },
|
||||
});
|
||||
expect(providerEvents.map((event) => event.type)).toEqual([
|
||||
"message.appended",
|
||||
"message.completed",
|
||||
@@ -717,6 +729,118 @@ describe("emitStreamContent action requests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a background subagent receipt on subagent.completed", async () => {
|
||||
const emit = createEmitStub();
|
||||
const tools = new Map<string, HarnessToolDefinition>([
|
||||
[
|
||||
"delegate",
|
||||
{
|
||||
description: "Delegate work to a subagent.",
|
||||
execution: "background",
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
name: "delegate",
|
||||
resultKind: "subagent",
|
||||
workflowId: "workflow//./agent/subagents/researcher//execute",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await emitStreamContent(
|
||||
emit,
|
||||
EMISSION_STATE,
|
||||
streamOf([
|
||||
{
|
||||
input: { message: "research the release" },
|
||||
toolCallId: "call-delegate",
|
||||
toolName: "delegate",
|
||||
type: "tool-call",
|
||||
},
|
||||
{
|
||||
output: { status: "working", taskId: "task-1" },
|
||||
toolCallId: "call-delegate",
|
||||
toolName: "delegate",
|
||||
type: "tool-result",
|
||||
},
|
||||
{ finishReason: "tool-calls", type: "finish-step" },
|
||||
] as TextStreamPart<ToolSet>[]),
|
||||
{ excludedActionToolNames: new Set(), tools },
|
||||
);
|
||||
|
||||
const events = vi.mocked(emit).mock.calls.map(([event]) => event);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"actions.requested",
|
||||
"subagent.completed",
|
||||
"action.result",
|
||||
]);
|
||||
expect(events[1]).toMatchObject({
|
||||
data: {
|
||||
backgroundTask: { status: "working", taskId: "task-1" },
|
||||
callId: "call-delegate",
|
||||
subagentName: "delegate",
|
||||
},
|
||||
type: "subagent.completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fail tool streaming when label projections throw", async () => {
|
||||
const tools = new Map<string, HarnessToolDefinition>([
|
||||
[
|
||||
"build_report",
|
||||
{
|
||||
label: {
|
||||
complete: () => {
|
||||
throw new Error("result projection failed");
|
||||
},
|
||||
delta: () => {
|
||||
throw new Error("update projection failed");
|
||||
},
|
||||
},
|
||||
description: "Build a report.",
|
||||
execute: async () => ({ phase: "complete" }),
|
||||
inputSchema: jsonSchema({ type: "object" }),
|
||||
name: "build_report",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const emit = createEmitStub();
|
||||
|
||||
await emitStreamContent(
|
||||
emit,
|
||||
EMISSION_STATE,
|
||||
streamOf([
|
||||
{
|
||||
input: {},
|
||||
toolCallId: "call-1",
|
||||
toolName: "build_report",
|
||||
type: "tool-call",
|
||||
},
|
||||
{
|
||||
output: { phase: "collecting" },
|
||||
preliminary: true,
|
||||
toolCallId: "call-1",
|
||||
toolName: "build_report",
|
||||
type: "tool-result",
|
||||
},
|
||||
{
|
||||
output: { phase: "complete" },
|
||||
toolCallId: "call-1",
|
||||
toolName: "build_report",
|
||||
type: "tool-result",
|
||||
},
|
||||
{ finishReason: "stop", type: "finish-step" },
|
||||
] as TextStreamPart<ToolSet>[]),
|
||||
{ excludedActionToolNames: new Set(), tools },
|
||||
);
|
||||
|
||||
expect(vi.mocked(emit).mock.calls.map(([event]) => event.type)).toEqual([
|
||||
"actions.requested",
|
||||
"action.partial",
|
||||
"action.result",
|
||||
]);
|
||||
expect(vi.mocked(emit).mock.calls[1]?.[0]).not.toHaveProperty("data.presentation");
|
||||
expect(vi.mocked(emit).mock.calls[2]?.[0]).not.toHaveProperty("data.presentation");
|
||||
});
|
||||
|
||||
it("projects local and provider tool failures at the same stream position", async () => {
|
||||
const tools = new Map<string, HarnessToolDefinition>([
|
||||
[
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
createPresentedRuntimeActionRequestFromToolCall,
|
||||
type RuntimeActionRequestProjection,
|
||||
} from "#harness/action-presentation.js";
|
||||
import { projectResultPresentation, projectDeltaPresentation } from "#harness/tool-presentation.js";
|
||||
import { createProviderStreamActionBatch } from "#harness/stream-actions.js";
|
||||
import { normalizeModelStreamError } from "#harness/model-call-error.js";
|
||||
import { createOrderedStreamEmitter } from "#harness/ordered-stream-emitter.js";
|
||||
@@ -70,10 +71,6 @@ export {
|
||||
} from "#harness/emission-state.js";
|
||||
export type { HarnessEmissionState } from "#harness/emission-state.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn lifecycle helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Emits `session.started` (once), `turn.started`, and `message.received` at the
|
||||
* beginning of a new turn. Returns updated emission state.
|
||||
@@ -242,10 +239,6 @@ export async function emitTurnEpilogue(
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream content emission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of consuming one step's `fullStream`.
|
||||
*
|
||||
@@ -335,6 +328,7 @@ async function consumeStreamContent(
|
||||
const invalidInputToolCallIds = new Set<string>();
|
||||
const inlineAuthorizationResults: TypedToolResult<ToolSet>[] = [];
|
||||
const trailingInlineToolResultParts: InlineToolResultPart[] = [];
|
||||
const actionInputs = new Map<string, JsonObject>();
|
||||
const streamingActionInputs = new Map<string, { toolName: string }>();
|
||||
|
||||
const flushCurrentMessage = async (): Promise<void> => {
|
||||
@@ -380,6 +374,7 @@ async function consumeStreamContent(
|
||||
}
|
||||
|
||||
emittedActionCallIds.add(action.callId);
|
||||
actionInputs.set(action.callId, action.input);
|
||||
await emitFn(
|
||||
createActionsRequestedEvent({
|
||||
actions: [action],
|
||||
@@ -420,6 +415,7 @@ async function consumeStreamContent(
|
||||
return;
|
||||
}
|
||||
|
||||
actionInputs.set(resolved.request.action.callId, resolved.request.action.input);
|
||||
providerActionBatch.observe(resolved.request);
|
||||
};
|
||||
|
||||
@@ -440,8 +436,18 @@ async function consumeStreamContent(
|
||||
type: "subagent.completed",
|
||||
});
|
||||
}
|
||||
const resultPresentation =
|
||||
result.isError === true
|
||||
? undefined
|
||||
: projectResultPresentation(
|
||||
options?.tools.get(result.toolName),
|
||||
result.callId,
|
||||
actionInputs.get(result.callId),
|
||||
result.output,
|
||||
);
|
||||
await emitFn(
|
||||
createActionResultEvent({
|
||||
presentation: resultPresentation,
|
||||
result,
|
||||
sequence: state.sequence,
|
||||
stepIndex: state.stepIndex,
|
||||
@@ -451,8 +457,15 @@ async function consumeStreamContent(
|
||||
};
|
||||
|
||||
const emitActionPartial = async (result: RuntimeToolResultActionResult): Promise<void> => {
|
||||
const deltaPresentation = projectDeltaPresentation(
|
||||
options?.tools.get(result.toolName),
|
||||
result.callId,
|
||||
actionInputs.get(result.callId),
|
||||
result.output,
|
||||
);
|
||||
await emitFn(
|
||||
createActionPartialEvent({
|
||||
presentation: deltaPresentation,
|
||||
result,
|
||||
sequence: state.sequence,
|
||||
stepIndex: state.stepIndex,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ActionPresentationByCallId } from "#protocol/message.js";
|
||||
import type { HarnessToolDefinition } from "#harness/execute-tool.js";
|
||||
import { normalizePresentationText } from "#shared/presentation-text.js";
|
||||
import { parseJsonObject, type JsonObject } from "#shared/json.js";
|
||||
|
||||
export function projectResultPresentation(
|
||||
definition: HarnessToolDefinition | undefined,
|
||||
callId: string,
|
||||
input: JsonObject | undefined,
|
||||
output: unknown,
|
||||
): ActionPresentationByCallId | undefined {
|
||||
return projectPresentationText(definition?.label?.complete, callId, input, output);
|
||||
}
|
||||
|
||||
export function projectDeltaPresentation(
|
||||
definition: HarnessToolDefinition | undefined,
|
||||
callId: string,
|
||||
input: JsonObject | undefined,
|
||||
output: unknown,
|
||||
): ActionPresentationByCallId | undefined {
|
||||
return projectPresentationText(definition?.label?.delta, callId, input, output);
|
||||
}
|
||||
|
||||
function projectPresentationText(
|
||||
project: ((input: unknown, value: unknown) => string) | undefined,
|
||||
callId: string,
|
||||
input: JsonObject | undefined,
|
||||
value: unknown,
|
||||
): ActionPresentationByCallId | undefined {
|
||||
if (project === undefined || input === undefined) return undefined;
|
||||
try {
|
||||
const text = normalizePresentationText(project(parseJsonObject(input), value));
|
||||
return text === "" ? undefined : { [callId]: { label: text } };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,17 @@ describe("normalizeToolDefinition", () => {
|
||||
FAILURE_MESSAGE,
|
||||
),
|
||||
).toThrow(FAILURE_MESSAGE);
|
||||
expect(() =>
|
||||
normalizeToolDefinition(
|
||||
{
|
||||
label: { label: () => "Fetch weather", result: "Done" },
|
||||
description: "Fetch weather.",
|
||||
execute: () => null,
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
FAILURE_MESSAGE,
|
||||
),
|
||||
).toThrow(FAILURE_MESSAGE);
|
||||
});
|
||||
|
||||
it("accepts authored tools that declare a `toModelOutput` function", () => {
|
||||
|
||||
@@ -180,8 +180,10 @@ export function normalizeToolDefinition(value: unknown, message: string): Normal
|
||||
*/
|
||||
if (record.label !== undefined) {
|
||||
const label = expectObjectRecord(record.label, message);
|
||||
expectOnlyKnownKeys(label, ["start"], message);
|
||||
expectOnlyKnownKeys(label, ["start", "complete", "delta"], message);
|
||||
expectFunction(label.start, message);
|
||||
if (label.complete !== undefined) expectFunction(label.complete, message);
|
||||
if (label.delta !== undefined) expectFunction(label.delta, message);
|
||||
}
|
||||
|
||||
if (record.approval !== undefined) {
|
||||
|
||||
@@ -52,7 +52,7 @@ async function transformAndEval(
|
||||
stampDurableDynamicToolCallbacks(
|
||||
entry,
|
||||
collectDurableDynamicToolCallbacks({
|
||||
label: entry.label as { start?: never } | undefined,
|
||||
label: entry.label as { complete?: never; delta?: never; start?: never } | undefined,
|
||||
approval: entry.approval as never,
|
||||
approvalKey: entry.approvalKey as never,
|
||||
execute: entry.execute as never,
|
||||
@@ -80,7 +80,7 @@ async function transformAndEval(
|
||||
|
||||
type StampedCallback = { callback: Function; closure: Record<string, unknown> };
|
||||
type StampedCallbacks = Record<string, StampedCallback> & {
|
||||
label?: { start?: StampedCallback };
|
||||
label?: { complete?: StampedCallback; delta?: StampedCallback; start?: StampedCallback };
|
||||
};
|
||||
|
||||
function durableCallbacks(tool: unknown): StampedCallbacks {
|
||||
@@ -110,6 +110,8 @@ export default defineDynamic({
|
||||
"session.started": async () => {
|
||||
const labelPrefix = "Deploy";
|
||||
const executePrefix = "execute";
|
||||
const resultPrefix = "Deployed to";
|
||||
const updateSuffix = " sources";
|
||||
const requestReason = "confirm";
|
||||
const allowedResponder = "user-123";
|
||||
const projectionPrefix = "visible";
|
||||
@@ -121,6 +123,12 @@ export default defineDynamic({
|
||||
start(input) {
|
||||
return labelPrefix + " " + input.value;
|
||||
},
|
||||
complete(_input, output) {
|
||||
return resultPrefix + " " + output.url;
|
||||
},
|
||||
delta(_input, partial) {
|
||||
return partial.phase + updateSuffix;
|
||||
},
|
||||
},
|
||||
approval: {
|
||||
request(ctx) {
|
||||
@@ -159,17 +167,21 @@ export default defineDynamic({
|
||||
]);
|
||||
expect(callbacks.execute!.closure).toEqual({ executePrefix: "execute" });
|
||||
expect(callbacks.label?.start?.closure).toEqual({ labelPrefix: "Deploy" });
|
||||
expect(callbacks.label?.complete?.closure).toEqual({ resultPrefix: "Deployed to" });
|
||||
expect(callbacks.label?.delta?.closure).toEqual({ updateSuffix: " sources" });
|
||||
expect(callbacks.approvalRequest!.closure).toEqual({ requestReason: "confirm" });
|
||||
expect(callbacks.approvalResponse!.closure).toEqual({ allowedResponder: "user-123" });
|
||||
expect(callbacks.toModelOutput!.closure).toEqual({ projectionPrefix: "visible" });
|
||||
const callbackValues = [
|
||||
callbacks.execute,
|
||||
callbacks.label?.start,
|
||||
callbacks.label?.complete,
|
||||
callbacks.label?.delta,
|
||||
callbacks.approvalRequest,
|
||||
callbacks.approvalResponse,
|
||||
callbacks.toModelOutput,
|
||||
];
|
||||
expect(new Set(callbackValues.map((callback) => callback!.callback)).size).toBe(5);
|
||||
expect(new Set(callbackValues.map((callback) => callback!.callback)).size).toBe(7);
|
||||
for (const callback of callbackValues) expect(callback!.callback).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "#internal/workflow-bundle/dynamic-tool-ast-references.js";
|
||||
|
||||
type CallbackPhase =
|
||||
| "labelComplete"
|
||||
| "labelDelta"
|
||||
| "labelStart"
|
||||
| "approvalKey"
|
||||
| "approvalRequest"
|
||||
@@ -28,6 +30,8 @@ type CallbackPropertyName =
|
||||
| "start"
|
||||
| "request"
|
||||
| "response"
|
||||
| "complete"
|
||||
| "delta"
|
||||
| "toModelOutput";
|
||||
|
||||
interface CallbackInfo {
|
||||
@@ -185,6 +189,14 @@ function collectToolCallbacks(
|
||||
if (!isWorkflowExecute(execute, context)) {
|
||||
collectCallbackProperty(source, execute, "execute", "execute", results, nestedScopes);
|
||||
}
|
||||
collectCallbackProperty(
|
||||
source,
|
||||
findProperty(tool, "approvalKey"),
|
||||
"approvalKey",
|
||||
"approvalKey",
|
||||
results,
|
||||
nestedScopes,
|
||||
);
|
||||
const label = findProperty(tool, "label");
|
||||
const labelValue = label?.value as AstNode | undefined;
|
||||
if (labelValue?.type === "ObjectExpression") {
|
||||
@@ -196,6 +208,22 @@ function collectToolCallbacks(
|
||||
results,
|
||||
nestedScopes,
|
||||
);
|
||||
collectCallbackProperty(
|
||||
source,
|
||||
findProperty(labelValue, "complete"),
|
||||
"labelComplete",
|
||||
"complete",
|
||||
results,
|
||||
nestedScopes,
|
||||
);
|
||||
collectCallbackProperty(
|
||||
source,
|
||||
findProperty(labelValue, "delta"),
|
||||
"labelDelta",
|
||||
"delta",
|
||||
results,
|
||||
nestedScopes,
|
||||
);
|
||||
}
|
||||
collectCallbackProperty(
|
||||
source,
|
||||
@@ -206,15 +234,6 @@ function collectToolCallbacks(
|
||||
nestedScopes,
|
||||
);
|
||||
|
||||
collectCallbackProperty(
|
||||
source,
|
||||
findProperty(tool, "approvalKey"),
|
||||
"approvalKey",
|
||||
"approvalKey",
|
||||
results,
|
||||
nestedScopes,
|
||||
);
|
||||
|
||||
const approval = findProperty(tool, "approval");
|
||||
const approvalValue = approval?.value as AstNode | undefined;
|
||||
if (approvalValue?.type === "ObjectExpression") {
|
||||
|
||||
@@ -92,7 +92,23 @@ describe("Slack activity plan", () => {
|
||||
snapshot: started,
|
||||
state: undefined,
|
||||
});
|
||||
const expanded = reduceActivityBatch(started, {
|
||||
const updated = reduceActivityBatch(started, {
|
||||
version: 1,
|
||||
events: [
|
||||
{
|
||||
actionId: "verify-action",
|
||||
eventId: "verify-action-delta",
|
||||
kind: "action.label.updated",
|
||||
label: "Verifying tests",
|
||||
},
|
||||
],
|
||||
});
|
||||
const updatedState = await renderer.render({
|
||||
destination: { channelId: "C1", threadTs: "T1", teamId: "TEAM", triggeringUserId: "USER" },
|
||||
snapshot: updated,
|
||||
state,
|
||||
});
|
||||
const expanded = reduceActivityBatch(updated, {
|
||||
version: 1,
|
||||
events: [
|
||||
{ eventId: "reviewer", kind: "work.started", startedAt: "3", work: reviewer },
|
||||
@@ -102,7 +118,7 @@ describe("Slack activity plan", () => {
|
||||
const expandedState = await renderer.render({
|
||||
destination: { channelId: "C1", threadTs: "T1", teamId: "TEAM", triggeringUserId: "USER" },
|
||||
snapshot: expanded,
|
||||
state,
|
||||
state: updatedState,
|
||||
});
|
||||
const settled = reduceActivityBatch(expanded, {
|
||||
version: 1,
|
||||
@@ -154,16 +170,18 @@ describe("Slack activity plan", () => {
|
||||
"chat.appendStream",
|
||||
"chat.appendStream",
|
||||
"chat.appendStream",
|
||||
"chat.appendStream",
|
||||
"chat.stopStream",
|
||||
"chat.update",
|
||||
]);
|
||||
expect(requests[1]!.body.get("chunks")).toContain("• verify_stage\\n");
|
||||
expect(requests[1]!.body.get("chunks")).toContain("• Verify release\\n");
|
||||
expect(requests[2]!.body.get("chunks")).toContain('"id":"reviewer"');
|
||||
expect(requests[2]!.body.get("chunks")).toContain("• review_stage\\n");
|
||||
expect(requests[3]!.body.get("chunks")).toContain("✓ verify_stage\\n");
|
||||
expect(requests[3]!.body.get("chunks")).toContain("✓ review_stage\\n");
|
||||
expect(requests[5]!.body.get("blocks")).not.toContain("verify_stage");
|
||||
expect(requests[5]!.body.get("blocks")).toContain("verifier");
|
||||
expect(requests[2]!.body.get("chunks")).toContain("• Verifying tests\\n");
|
||||
expect(requests[3]!.body.get("chunks")).toContain('"id":"reviewer"');
|
||||
expect(requests[3]!.body.get("chunks")).toContain("• review_stage\\n");
|
||||
expect(requests[4]!.body.get("chunks")).toContain("✓ verify_stage\\n");
|
||||
expect(requests[4]!.body.get("chunks")).toContain("✓ review_stage\\n");
|
||||
expect(requests[6]!.body.get("blocks")).not.toContain("verify_stage");
|
||||
expect(requests[6]!.body.get("blocks")).toContain("verifier");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ interface PlanState {
|
||||
string,
|
||||
{
|
||||
readonly ts: string;
|
||||
readonly seen: Readonly<Record<string, Phase>>;
|
||||
readonly seen: Readonly<Record<string, string>>;
|
||||
readonly stopped: boolean;
|
||||
}
|
||||
>
|
||||
@@ -40,7 +40,7 @@ export function createSlackPlanRenderer(
|
||||
const previous = isState(state) ? state.streams : {};
|
||||
const streams: Record<
|
||||
string,
|
||||
{ ts: string; seen: Readonly<Record<string, Phase>>; stopped: boolean }
|
||||
{ ts: string; seen: Readonly<Record<string, string>>; stopped: boolean }
|
||||
> = { ...previous };
|
||||
for (const rootTurnId of new Set(
|
||||
Object.values(snapshot.work).map((work) => work.rootTurnId),
|
||||
@@ -84,9 +84,10 @@ export function createSlackPlanRenderer(
|
||||
botToken,
|
||||
installation,
|
||||
);
|
||||
const seen = Object.fromEntries(
|
||||
[...view.parents, ...view.entities].map((entity) => [entity.id, entity.phase]),
|
||||
);
|
||||
const seen = Object.fromEntries([
|
||||
...view.parents.map((parent) => [parent.id, parent.phase]),
|
||||
...view.entities.map((entity) => [entity.id, entityVersion(entity)]),
|
||||
]);
|
||||
if (view.settled) {
|
||||
await checked("chat.stopStream", { channel, ts: current.ts }, botToken, installation);
|
||||
const blocks = [
|
||||
@@ -179,12 +180,12 @@ function project(snapshot: ActivitySnapshotV1, rootTurnId: string): View {
|
||||
].every((e) => e.phase !== "running" && e.phase !== "blocked");
|
||||
return { parents, entities, settled };
|
||||
}
|
||||
function detailUpdates(view: View, seen: Readonly<Record<string, Phase>>) {
|
||||
function detailUpdates(view: View, seen: Readonly<Record<string, string>>) {
|
||||
const parentUpdates = view.parents
|
||||
.filter((parent) => seen[parent.id] !== parent.phase)
|
||||
.map(taskChunk);
|
||||
const descendantUpdates = view.entities
|
||||
.filter((entity) => seen[entity.id] !== entity.phase)
|
||||
.filter((entity) => seen[entity.id] !== entityVersion(entity))
|
||||
.map((entity) => ({
|
||||
type: "task_update",
|
||||
id: safeId(entity.parent),
|
||||
@@ -196,6 +197,9 @@ function detailUpdates(view: View, seen: Readonly<Record<string, Phase>>) {
|
||||
}));
|
||||
return [...parentUpdates, ...descendantUpdates];
|
||||
}
|
||||
function entityVersion(entity: Entity): string {
|
||||
return `${entity.phase}:${entity.name}`;
|
||||
}
|
||||
function taskChunk(work: ActivityWorkStateV1) {
|
||||
return {
|
||||
type: "task_update",
|
||||
|
||||
@@ -97,6 +97,20 @@ describe("Slack activity activity", () => {
|
||||
|
||||
expect(activityMessages(labeled).get("turn")).toContain("Search Slack docs");
|
||||
expect(selectSlackActivityStatus(labeled)).toBe("Search Slack docs");
|
||||
|
||||
const completed = reduceActivityBatch(labeled, {
|
||||
events: [
|
||||
{
|
||||
actionId: `${grandchild.id}:search`,
|
||||
eventId: "search-complete-label",
|
||||
kind: "action.label.updated",
|
||||
label: "Found Slack docs",
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
});
|
||||
expect(activityMessages(completed).get("turn")).toContain("Found Slack docs");
|
||||
expect(selectSlackActivityStatus(completed)).toBe("Found Slack docs");
|
||||
});
|
||||
|
||||
it("renders a background task instead of its duplicate initiating tool action", () => {
|
||||
|
||||
@@ -62,6 +62,17 @@ describe("definition helper exact inputs", () => {
|
||||
|
||||
it("accepts async-generator tool executors", () => {
|
||||
const streamedTool = defineTool({
|
||||
label: {
|
||||
start: () => "Build report",
|
||||
complete(_input, output) {
|
||||
expectTypeOf(output.phase).toEqualTypeOf<string>();
|
||||
return `Report ${output.phase}`;
|
||||
},
|
||||
delta(_input, partial) {
|
||||
expectTypeOf(partial.phase).toEqualTypeOf<string>();
|
||||
return partial.phase;
|
||||
},
|
||||
},
|
||||
description: "Stream report progress.",
|
||||
inputSchema: { type: "object" },
|
||||
async *execute() {
|
||||
|
||||
@@ -31,12 +31,15 @@ function moduleMap(value: unknown): CompiledModuleMap {
|
||||
}
|
||||
|
||||
describe("resolveToolDefinition", () => {
|
||||
it("reattaches the authored label start callback callback", async () => {
|
||||
it("reattaches authored label callbacks", async () => {
|
||||
const resolved = await resolveToolDefinition(
|
||||
definition,
|
||||
moduleMap({
|
||||
label: {
|
||||
start: (input: { environment: string }) => `Deploy to ${input.environment}`,
|
||||
complete: (_input: { environment: string }, output: { url: string }) =>
|
||||
`Deployed to ${output.url}`,
|
||||
delta: (_input: { environment: string }, partial: { phase: string }) => partial.phase,
|
||||
},
|
||||
description: definition.description,
|
||||
execute: () => null,
|
||||
@@ -47,6 +50,12 @@ describe("resolveToolDefinition", () => {
|
||||
);
|
||||
|
||||
expect(resolved.label?.start?.({ environment: "production" })).toBe("Deploy to production");
|
||||
expect(
|
||||
resolved.label?.complete?.({ environment: "production" }, { url: "https://example.com" }),
|
||||
).toBe("Deployed to https://example.com");
|
||||
expect(resolved.label?.delta?.({ environment: "production" }, { phase: "Uploading" })).toBe(
|
||||
"Uploading",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,11 +111,9 @@ export async function resolveToolDefinition(
|
||||
* result without clobbering required fields with `undefined`.
|
||||
*/
|
||||
type OptionalResolvedFields = {
|
||||
-readonly [K in
|
||||
| "label"
|
||||
| "approval"
|
||||
| "approvalKey"
|
||||
| "toModelOutput"]?: ResolvedToolDefinition[K];
|
||||
-readonly [
|
||||
K in "label" | "approval" | "approvalKey" | "toModelOutput"
|
||||
]?: ResolvedToolDefinition[K];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -135,6 +133,20 @@ function extractOptionalHooks(
|
||||
describe(definition, "to provide a valid label definition"),
|
||||
);
|
||||
optional.label = {
|
||||
complete:
|
||||
label.complete === undefined
|
||||
? undefined
|
||||
: (expectFunction(
|
||||
label.complete,
|
||||
describe(definition, "to provide an label complete function"),
|
||||
) as NonNullable<ResolvedToolDefinition["label"]>["complete"]),
|
||||
delta:
|
||||
label.delta === undefined
|
||||
? undefined
|
||||
: (expectFunction(
|
||||
label.delta,
|
||||
describe(definition, "to provide an label delta function"),
|
||||
) as NonNullable<ResolvedToolDefinition["label"]>["delta"]),
|
||||
start: expectFunction(
|
||||
label.start,
|
||||
describe(definition, "to provide a label start callback function"),
|
||||
|
||||
@@ -40,13 +40,13 @@ interface ToolDefinitionBase {
|
||||
readonly execution?: ToolExecution;
|
||||
}
|
||||
|
||||
export interface ToolLabelDefinition<TInput = unknown> {
|
||||
/** Returns the presentation-safe label for one action invocation. */
|
||||
export interface ToolLabelDefinition<TInput = unknown, TOutput = unknown> {
|
||||
/** Returns the presentation-safe label when one action invocation starts. */
|
||||
start(input: Readonly<TInput>): string;
|
||||
}
|
||||
|
||||
export interface InternalToolLabelDefinition {
|
||||
readonly start?: (input: unknown) => string;
|
||||
/** Projects one preliminary output snapshot into presentation-safe label text. */
|
||||
delta?(input: Readonly<TInput>, partial: Readonly<TOutput>): string;
|
||||
/** Projects a successful final output into presentation-safe settlement text. */
|
||||
complete?(input: Readonly<TInput>, output: Readonly<TOutput>): string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +56,12 @@ export interface InternalToolLabelDefinition {
|
||||
* Authored public definitions (see {@link PublicToolDefinition}) do not
|
||||
* carry `name`; identity comes from the file path.
|
||||
*/
|
||||
export interface InternalToolLabelDefinition {
|
||||
readonly complete?: (input: unknown, output: unknown) => string;
|
||||
readonly delta?: (input: unknown, partial: unknown) => string;
|
||||
readonly start?: (input: unknown) => string;
|
||||
}
|
||||
|
||||
export interface InternalToolDefinition extends ToolDefinitionBase {
|
||||
label?: InternalToolLabelDefinition;
|
||||
name: string;
|
||||
@@ -80,7 +86,7 @@ export interface PublicToolDefinition<
|
||||
TInput = unknown,
|
||||
TOutput = unknown,
|
||||
> extends ToolDefinitionBase {
|
||||
label?: ToolLabelDefinition<TInput>;
|
||||
label?: ToolLabelDefinition<TInput, TOutput>;
|
||||
inputSchema: PublicToolInputSchema<TInput>;
|
||||
/**
|
||||
* Optional schema describing the value returned by the tool executor.
|
||||
@@ -267,7 +273,10 @@ export function defineTool<
|
||||
inputSchema: TSchema;
|
||||
outputSchema?: PublicToolDefinition<unknown, TaskReceipt>["outputSchema"];
|
||||
execute(input: StandardSchemaV1.InferOutput<TSchema>, ctx: ToolContext, task: TaskExec): TReturn;
|
||||
label?: BackgroundToolDefinition<StandardSchemaV1.InferOutput<TSchema>, unknown>["label"];
|
||||
label?: BackgroundToolDefinition<
|
||||
StandardSchemaV1.InferOutput<TSchema>,
|
||||
BackgroundToolOutputFromExecuteReturn<TReturn>
|
||||
>["label"];
|
||||
approval?: BackgroundToolDefinition<StandardSchemaV1.InferOutput<TSchema>, unknown>["approval"];
|
||||
approvalKey?: BackgroundToolDefinition<
|
||||
StandardSchemaV1.InferOutput<TSchema>,
|
||||
@@ -294,7 +303,10 @@ export function defineTool<
|
||||
inputSchema: TInputSchema;
|
||||
outputSchema: TOutputSchema;
|
||||
execute(input: StandardSchemaV1.InferOutput<TInputSchema>, ctx: ToolContext): TReturn;
|
||||
label?: ToolDefinition<StandardSchemaV1.InferOutput<TInputSchema>, unknown>["label"];
|
||||
label?: ToolDefinition<
|
||||
StandardSchemaV1.InferOutput<TInputSchema>,
|
||||
StandardJSONSchemaV1.InferOutput<TOutputSchema>
|
||||
>["label"];
|
||||
approval?: ToolDefinition<StandardSchemaV1.InferOutput<TInputSchema>, unknown>["approval"];
|
||||
approvalKey?: ToolDefinition<StandardSchemaV1.InferOutput<TInputSchema>, unknown>["approvalKey"];
|
||||
toModelOutput?: ToolDefinition<
|
||||
@@ -314,7 +326,10 @@ export function defineTool<
|
||||
inputSchema: TSchema;
|
||||
outputSchema?: JsonObject;
|
||||
execute(input: StandardSchemaV1.InferOutput<TSchema>, ctx: ToolContext): TReturn;
|
||||
label?: ToolDefinition<StandardSchemaV1.InferOutput<TSchema>, unknown>["label"];
|
||||
label?: ToolDefinition<
|
||||
StandardSchemaV1.InferOutput<TSchema>,
|
||||
ToolOutputFromExecuteReturn<TReturn>
|
||||
>["label"];
|
||||
approval?: ToolDefinition<StandardSchemaV1.InferOutput<TSchema>, unknown>["approval"];
|
||||
approvalKey?: ToolDefinition<StandardSchemaV1.InferOutput<TSchema>, unknown>["approvalKey"];
|
||||
toModelOutput?: ToolDefinition<unknown, ToolOutputFromExecuteReturn<TReturn>>["toModelOutput"];
|
||||
@@ -334,7 +349,10 @@ export function defineTool<
|
||||
inputSchema: JsonObject;
|
||||
outputSchema: TOutputSchema;
|
||||
execute(input: Record<string, unknown>, ctx: ToolContext): TReturn;
|
||||
label?: ToolDefinition<Record<string, unknown>, unknown>["label"];
|
||||
label?: ToolDefinition<
|
||||
Record<string, unknown>,
|
||||
StandardJSONSchemaV1.InferOutput<TOutputSchema>
|
||||
>["label"];
|
||||
approval?: ToolDefinition<Record<string, unknown>, unknown>["approval"];
|
||||
approvalKey?: ToolDefinition<Record<string, unknown>, unknown>["approvalKey"];
|
||||
toModelOutput?: ToolDefinition<
|
||||
@@ -351,7 +369,7 @@ export function defineTool<TReturn>(definition: {
|
||||
inputSchema: JsonObject;
|
||||
outputSchema?: JsonObject;
|
||||
execute(input: Record<string, unknown>, ctx: ToolContext): TReturn;
|
||||
label?: ToolDefinition<Record<string, unknown>, unknown>["label"];
|
||||
label?: ToolDefinition<Record<string, unknown>, ToolOutputFromExecuteReturn<TReturn>>["label"];
|
||||
approval?: ToolDefinition<Record<string, unknown>, unknown>["approval"];
|
||||
approvalKey?: ToolDefinition<Record<string, unknown>, unknown>["approvalKey"];
|
||||
toModelOutput?: ToolDefinition<unknown, ToolOutputFromExecuteReturn<TReturn>>["toModelOutput"];
|
||||
|
||||
@@ -2,54 +2,77 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clearDurableDynamicCallbacks,
|
||||
lookupDurableDynamicCallback,
|
||||
hasUnregisteredDurableDynamicCallbacks,
|
||||
registerDurableDynamicCallback,
|
||||
type DurableDynamicCallbackPhase,
|
||||
type DynamicToolCallbackOwner,
|
||||
} from "#tools/durable-callbacks.js";
|
||||
|
||||
const owner: DynamicToolCallbackOwner = {
|
||||
sessionId: "cache-owner",
|
||||
scope: "session",
|
||||
resolverSlug: "search",
|
||||
entryKey: "query",
|
||||
name: "query",
|
||||
};
|
||||
const reference = { closure: {} };
|
||||
|
||||
describe("durable callback cache", () => {
|
||||
it("evicts old sessions and allows their own callback to rebind", () => {
|
||||
const callback = () => "original";
|
||||
registerDurableDynamicCallback({ owner, phase: "execute", callback });
|
||||
for (let index = 0; index < 1_024; index++) {
|
||||
registerDurableDynamicCallback({
|
||||
owner: { ...owner, sessionId: `other-${index}` },
|
||||
phase: "execute",
|
||||
callback: () => "other session",
|
||||
});
|
||||
function owner(name: string): DynamicToolCallbackOwner {
|
||||
return {
|
||||
entryKey: name,
|
||||
name,
|
||||
resolverSlug: "test",
|
||||
scope: "session",
|
||||
sessionId: name,
|
||||
};
|
||||
}
|
||||
|
||||
describe("hasUnregisteredDurableDynamicCallbacks", () => {
|
||||
it("checks nested label references by their registry phases", () => {
|
||||
const callbackOwner = owner("nested-label-registration-test");
|
||||
const phases: DurableDynamicCallbackPhase[] = [
|
||||
"execute",
|
||||
"labelComplete",
|
||||
"labelDelta",
|
||||
"labelStart",
|
||||
];
|
||||
for (const phase of phases) {
|
||||
registerDurableDynamicCallback({ callback: () => undefined, phase, owner: callbackOwner });
|
||||
}
|
||||
expect(lookupDurableDynamicCallback(owner, "execute")).toBeUndefined();
|
||||
registerDurableDynamicCallback({ owner, phase: "execute", callback });
|
||||
expect(lookupDurableDynamicCallback(owner, "execute")).toBe(callback);
|
||||
clearDurableDynamicCallbacks(owner.sessionId);
|
||||
for (let index = 0; index < 1_024; index++) clearDurableDynamicCallbacks(`other-${index}`);
|
||||
|
||||
expect(
|
||||
hasUnregisteredDurableDynamicCallbacks(
|
||||
[
|
||||
{
|
||||
callbacks: {
|
||||
label: { complete: reference, delta: reference, start: reference },
|
||||
execute: reference,
|
||||
},
|
||||
entryKey: callbackOwner.entryKey,
|
||||
name: callbackOwner.name,
|
||||
resolverSlug: callbackOwner.resolverSlug,
|
||||
},
|
||||
],
|
||||
{ scope: callbackOwner.scope, sessionId: callbackOwner.sessionId },
|
||||
),
|
||||
).toBe(false);
|
||||
clearDurableDynamicCallbacks(callbackOwner.sessionId);
|
||||
});
|
||||
|
||||
it("removes only the replaced resolver and scope", () => {
|
||||
const callback = () => null;
|
||||
for (const scope of ["session", "turn"] as const) {
|
||||
for (const resolverSlug of ["search", "other"]) {
|
||||
registerDurableDynamicCallback({
|
||||
owner: { ...owner, scope, resolverSlug },
|
||||
phase: "execute",
|
||||
callback,
|
||||
});
|
||||
}
|
||||
}
|
||||
clearDurableDynamicCallbacks(owner.sessionId, { scope: "session", resolverSlug: "search" });
|
||||
expect(lookupDurableDynamicCallback(owner, "execute")).toBeUndefined();
|
||||
expect(lookupDurableDynamicCallback({ ...owner, scope: "turn" }, "execute")).toBe(callback);
|
||||
expect(lookupDurableDynamicCallback({ ...owner, resolverSlug: "other" }, "execute")).toBe(
|
||||
callback,
|
||||
);
|
||||
clearDurableDynamicCallbacks(owner.sessionId);
|
||||
it("detects a missing nested label phase", () => {
|
||||
const callbackOwner = owner("missing-nested-label-registration-test");
|
||||
registerDurableDynamicCallback({
|
||||
callback: () => undefined,
|
||||
owner: callbackOwner,
|
||||
phase: "execute",
|
||||
});
|
||||
|
||||
expect(
|
||||
hasUnregisteredDurableDynamicCallbacks(
|
||||
[
|
||||
{
|
||||
callbacks: { execute: reference, label: { delta: reference } },
|
||||
entryKey: callbackOwner.entryKey,
|
||||
name: callbackOwner.name,
|
||||
resolverSlug: callbackOwner.resolverSlug,
|
||||
},
|
||||
],
|
||||
{ scope: callbackOwner.scope, sessionId: callbackOwner.sessionId },
|
||||
),
|
||||
).toBe(true);
|
||||
clearDurableDynamicCallbacks(callbackOwner.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { resolveApprovalPolicy } from "#approval/definition.js";
|
||||
import type { JsonObject } from "#shared/json.js";
|
||||
|
||||
export type DurableDynamicCallbackPhase =
|
||||
| "labelComplete"
|
||||
| "labelDelta"
|
||||
| "labelStart"
|
||||
| "approvalKey"
|
||||
| "approvalRequest"
|
||||
@@ -26,6 +28,8 @@ export interface DurableDynamicCallbackReference {
|
||||
export interface DurableDynamicToolCallbacks {
|
||||
readonly execute: DurableDynamicCallbackReference;
|
||||
readonly label?: {
|
||||
readonly complete?: DurableDynamicCallbackReference;
|
||||
readonly delta?: DurableDynamicCallbackReference;
|
||||
readonly start?: DurableDynamicCallbackReference;
|
||||
};
|
||||
readonly approvalKey?: DurableDynamicCallbackReference;
|
||||
@@ -43,6 +47,8 @@ export interface StampedDurableDynamicCallback {
|
||||
export type LiveDurableDynamicToolCallbacks = Partial<{
|
||||
execute: StampedDurableDynamicCallback;
|
||||
label: {
|
||||
readonly complete?: StampedDurableDynamicCallback;
|
||||
readonly delta?: StampedDurableDynamicCallback;
|
||||
readonly start?: StampedDurableDynamicCallback;
|
||||
};
|
||||
approvalKey: StampedDurableDynamicCallback;
|
||||
@@ -149,12 +155,31 @@ export function hasUnregisteredDurableDynamicCallbacks(
|
||||
scope: Pick<DynamicToolCallbackOwner, "sessionId" | "scope">,
|
||||
): boolean {
|
||||
return metadata.some((entry) =>
|
||||
(Object.keys(entry.callbacks) as DurableDynamicCallbackPhase[]).some(
|
||||
durableCallbackPhases(entry.callbacks).some(
|
||||
(phase) => lookupDurableDynamicCallback({ ...entry, ...scope }, phase) === undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function durableCallbackPhases(
|
||||
callbacks: DurableDynamicToolCallbacks,
|
||||
): DurableDynamicCallbackPhase[] {
|
||||
const entries: readonly (readonly [
|
||||
DurableDynamicCallbackPhase,
|
||||
DurableDynamicCallbackReference | undefined,
|
||||
])[] = [
|
||||
["execute", callbacks.execute],
|
||||
["labelComplete", callbacks.label?.complete],
|
||||
["labelDelta", callbacks.label?.delta],
|
||||
["labelStart", callbacks.label?.start],
|
||||
["approvalKey", callbacks.approvalKey],
|
||||
["approvalRequest", callbacks.approvalRequest],
|
||||
["approvalResponse", callbacks.approvalResponse],
|
||||
["toModelOutput", callbacks.toModelOutput],
|
||||
];
|
||||
return entries.flatMap(([phase, reference]) => (reference === undefined ? [] : [phase]));
|
||||
}
|
||||
|
||||
/** Marks a live callback with the descriptor needed to register it at resolve time. */
|
||||
export function stampDurableDynamicCallback<TCallback extends (...args: never[]) => unknown>(
|
||||
callback: TCallback,
|
||||
@@ -187,6 +212,8 @@ export function stampDurableDynamicToolCallbacks(
|
||||
|
||||
export function collectDurableDynamicToolCallbacks(input: {
|
||||
readonly label?: {
|
||||
readonly complete?: (...args: never[]) => unknown;
|
||||
readonly delta?: (...args: never[]) => unknown;
|
||||
readonly start?: (...args: never[]) => unknown;
|
||||
};
|
||||
readonly approval?: Approval<never>;
|
||||
@@ -194,6 +221,8 @@ export function collectDurableDynamicToolCallbacks(input: {
|
||||
readonly execute: (...args: never[]) => unknown;
|
||||
readonly toModelOutput?: (...args: never[]) => unknown;
|
||||
}): LiveDurableDynamicToolCallbacks {
|
||||
const labelComplete = readDurableDynamicCallback(input.label?.complete);
|
||||
const labelDelta = readDurableDynamicCallback(input.label?.delta);
|
||||
const labelStart = readDurableDynamicCallback(input.label?.start);
|
||||
const approvalRequest =
|
||||
input.approval === undefined
|
||||
@@ -209,7 +238,13 @@ export function collectDurableDynamicToolCallbacks(input: {
|
||||
const toModelOutput = readDurableDynamicCallback(input.toModelOutput);
|
||||
const callbacks: LiveDurableDynamicToolCallbacks = {};
|
||||
if (execute !== undefined) callbacks.execute = execute;
|
||||
if (labelStart !== undefined) callbacks.label = { start: labelStart };
|
||||
if (labelComplete !== undefined || labelDelta !== undefined || labelStart !== undefined) {
|
||||
callbacks.label = {
|
||||
complete: labelComplete,
|
||||
delta: labelDelta,
|
||||
start: labelStart,
|
||||
};
|
||||
}
|
||||
if (approvalKey !== undefined) callbacks.approvalKey = approvalKey;
|
||||
if (approvalRequest !== undefined) callbacks.approvalRequest = approvalRequest;
|
||||
if (approvalResponse !== undefined) callbacks.approvalResponse = approvalResponse;
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ToolModelOutput } from "#tools/model-output.js";
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface DynamicToolEntry<TInput = Record<string, unknown>, TOutput = any> {
|
||||
readonly label?: ToolLabelDefinition<TInput>;
|
||||
readonly label?: ToolLabelDefinition<TInput, TOutput>;
|
||||
readonly description: string;
|
||||
readonly inputSchema: PublicToolInputSchema<TInput>;
|
||||
readonly outputSchema?: PublicToolOutputSchema<TOutput>;
|
||||
|
||||
Reference in New Issue
Block a user