feat: establish tool call state machine

This commit is contained in:
2026-08-03 15:27:52 +08:00
parent 9021db6963
commit c384368f66
8 changed files with 371 additions and 0 deletions
+82
View File
@@ -37,6 +37,32 @@ export type DeliveryState =
| { status: "delivered"; updated_at: string; transport_id?: string }
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
/** The only standard interaction requests admitted during Milestone 1. */
export type ToolCallKind = "choice" | "confirm" | "input";
export type ToolCallRequest = {
call_id: string;
tool: ToolCallKind;
title: string;
prompt: string;
expires_at?: string;
data: JsonObject;
};
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
export type ToolCallResult = {
call_id: string;
status: ToolCallFinalStatus;
result: JsonObject;
error?: string;
};
export type ToolCallCancel = {
call_id: string;
reason: string;
};
/**
* A user-originated action awaiting delivery through the Transport Adapter.
* It is data only: the Store owns retries and the Kernel owns state changes.
@@ -105,6 +131,9 @@ export type ConversationItem =
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string })
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
| (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel })
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope });
@@ -119,6 +148,9 @@ const SUPPORTED_TYPES = new Set([
"lineup.v1.agent.status",
"lineup.v1.agent.progress",
"lineup.v1.error",
"lineup.v1.tool.call",
"lineup.v1.tool.result",
"lineup.v1.tool.cancel",
"lineup.v1.ui.open",
"lineup.v1.ui.patch",
"lineup.v1.ui.close",
@@ -233,6 +265,44 @@ function validAppCallPayload(payload: JsonObject): boolean {
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
}
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
function optionalTimestamp(value: unknown): string | undefined {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
}
function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
const callID = identifier(payload.call_id);
const tool = text(payload.tool) as ToolCallKind;
if (!callID || !TOOL_KINDS.has(tool)) return undefined;
const expiresAt = optionalTimestamp(payload.expires_at);
if (payload.expires_at !== undefined && !expiresAt) return undefined;
const data = payload.data === undefined ? {} : object(payload.data);
if (!data) return undefined;
return {
call_id: callID,
tool,
title: text(payload.title),
prompt: text(payload.prompt),
...(expiresAt ? { expires_at: expiresAt } : {}),
data,
};
}
function parseToolResult(payload: JsonObject): ToolCallResult | undefined {
const callID = identifier(payload.call_id);
const status = text(payload.status) as ToolCallFinalStatus;
const result = payload.result === undefined ? {} : object(payload.result);
if (!callID || !TOOL_FINAL_STATUSES.has(status) || !result) return undefined;
return { call_id: callID, status, result, ...(text(payload.error) ? { error: text(payload.error) } : {}) };
}
function parseToolCancel(payload: JsonObject): ToolCallCancel | undefined {
const callID = identifier(payload.call_id);
return callID ? { call_id: callID, reason: text(payload.reason) } : undefined;
}
function fallbackItem(value: ProtocolFallback): ConversationItem {
return {
kind: "fallback",
@@ -289,6 +359,18 @@ export function decodeConversationItem(raw: string): ConversationItem {
? { kind: "error", message, ...(text(payload.code) ? { code: text(payload.code) } : {}), ...base }
: fallbackItem({ code: "invalid_payload", detail: "Error payload requires message.", envelope });
}
case "lineup.v1.tool.call": {
const call = parseToolCall(payload);
return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope });
}
case "lineup.v1.tool.result": {
const result = parseToolResult(payload);
return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope });
}
case "lineup.v1.tool.cancel": {
const cancel = parseToolCancel(payload);
return cancel ? { kind: "tool-cancel", cancel, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool cancel requires call_id.", envelope });
}
case "lineup.v1.ui.open":
case "lineup.v1.ui.patch":
case "lineup.v1.ui.close":