[3/5] feat(eve): add tool lifecycle labels (#2856)

Signed-off-by: benpankow <ben.pankow@vercel.com>
This commit is contained in:
Ben Pankow
2026-09-08 10:18:20 -07:00
committed by GitHub
parent 1a4f0b3ae8
commit 3c3a0df839
32 changed files with 766 additions and 115 deletions
+5
View File
@@ -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
View File
@@ -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",
);
});
+124
View File
@@ -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>([
[
+21 -8
View File
@@ -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() {
+10 -1
View File
@@ -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",
);
});
});
+17 -5
View File
@@ -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"),
+30 -12
View File
@@ -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);
});
});
+37 -2
View File
@@ -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;
+1 -1
View File
@@ -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>;