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
+3
View File
@@ -101,6 +101,9 @@ rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(
rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item));
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
rendererRegistry.register("error", (item, context) => context.renderError(item));
rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item));
rendererRegistry.register("tool-result", (item, context) => context.renderToolResult(item));
rendererRegistry.register("tool-cancel", (item, context) => context.renderToolCancel(item));
rendererRegistry.register("surface", (item, context) => context.renderSurface(item));
rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item));
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
+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":
@@ -71,4 +71,59 @@ describe("InteractionKernel", () => {
// The first id has expired from the bounded set and is accepted again.
expect(kernel.ingest({ raw: envelope({ id: "evt_status_thinking" }), source: "live" }).disposition).toBe("accepted");
});
it("projects tool calls by call_id and applies a result only after submission", () => {
const kernel = new InteractionKernel();
const call = kernel.ingest({
raw: envelope({
id: "evt_tool_call",
type: "lineup.v1.tool.call",
payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: {} },
}),
source: "sync",
received_at: "2026-08-03T08:00:00.000Z",
});
const duplicateCallID = kernel.ingest({
raw: envelope({
id: "evt_tool_call_duplicate_id",
type: "lineup.v1.tool.call",
payload: { call_id: "call_kernel_001", tool: "choice", title: "Ignored", prompt: "Ignored", data: {} },
}),
source: "live",
});
const earlyResult = kernel.ingest({
raw: envelope({ id: "evt_tool_result_early", type: "lineup.v1.tool.result", payload: { call_id: "call_kernel_001", status: "completed", result: {} } }),
source: "live",
});
expect(call.snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_kernel_001", status: "pending" })]);
expect(duplicateCallID.snapshot.tool_calls).toEqual([
expect.objectContaining({ request: expect.objectContaining({ title: "Choose" }) }),
]);
expect(earlyResult.snapshot.tool_calls).toEqual([expect.objectContaining({ status: "pending" })]);
expect(kernel.submitToolCall("call_kernel_001", "2026-08-03T08:01:00.000Z")).toMatchObject({ disposition: "accepted" });
const completed = kernel.ingest({
raw: envelope({ id: "evt_tool_result_completed", type: "lineup.v1.tool.result", payload: { call_id: "call_kernel_001", status: "completed", result: { selected: "alpha" } } }),
source: "live",
});
expect(completed.snapshot.tool_calls).toEqual([
expect.objectContaining({ call_id: "call_kernel_001", status: "completed", result: { selected: "alpha" } }),
]);
});
it("keeps malformed tool payloads on the safe fallback path", () => {
const kernel = new InteractionKernel();
const invalidCall = kernel.ingest({
raw: envelope({ id: "evt_invalid_tool_call", type: "lineup.v1.tool.call", payload: { call_id: "call_bad", tool: "shell", data: {} } }),
source: "sync",
});
const invalidResult = kernel.ingest({
raw: envelope({ id: "evt_invalid_tool_result", type: "lineup.v1.tool.result", payload: { call_id: "call_bad", status: "submitted", result: {} } }),
source: "sync",
});
expect(invalidCall.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
expect(invalidResult.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
});
});
+27
View File
@@ -2,7 +2,10 @@ import {
decodeConversationItem,
type Actor,
type ConversationItem,
type JsonObject,
type ToolCallFinalStatus,
} from "../protocol";
import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state";
/** Transport metadata is intentionally opaque to the protocol and renderer. */
export type IncomingMessage = {
@@ -23,6 +26,7 @@ export type AgentPresence = {
export type KernelSnapshot = {
seen_envelope_ids: number;
agent_presence: readonly AgentPresence[];
tool_calls: readonly ToolCallRecord[];
};
export type KernelResult =
@@ -39,6 +43,7 @@ export class InteractionKernel {
private readonly seenIDs = new Set<string>();
private readonly seenOrder: string[] = [];
private readonly presenceByActor = new Map<string, AgentPresence>();
private readonly toolCalls = new ToolCallStateMachine();
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
@@ -61,14 +66,24 @@ export class InteractionKernel {
return {
seen_envelope_ids: this.seenIDs.size,
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
tool_calls: this.toolCalls.snapshot(),
};
}
public submitToolCall(callID: string, updatedAt: string) {
return this.toolCalls.submit(callID, updatedAt);
}
public completeToolCall(callID: string, status: ToolCallFinalStatus, result: JsonObject, updatedAt: string, error?: string) {
return this.toolCalls.complete({ call_id: callID, status, result, ...(error ? { error } : {}) }, updatedAt);
}
/** Clear only the in-memory projection when the active conversation changes. */
public reset(): void {
this.seenIDs.clear();
this.seenOrder.length = 0;
this.presenceByActor.clear();
this.toolCalls.reset();
}
private remember(id: string): void {
@@ -81,6 +96,18 @@ export class InteractionKernel {
}
private project(item: ConversationItem, updatedAt: string): void {
if (item.kind === "tool-call" && item.envelope) {
this.toolCalls.receive(item.call, item.envelope.conversation_id, updatedAt);
return;
}
if (item.kind === "tool-cancel") {
this.toolCalls.cancel(item.cancel, updatedAt);
return;
}
if (item.kind === "tool-result") {
this.toolCalls.complete(item.result, updatedAt);
return;
}
if (item.kind !== "agent-status" || !item.envelope) return;
const actor = item.envelope.sender;
const actorKey = `${actor.kind}:${actor.id}`;
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { ToolCallRequest } from "../protocol";
import { ToolCallStateMachine } from "./tool-call-state";
const RECEIVED_AT = "2026-08-03T08:00:00.000Z";
function request(overrides: Partial<ToolCallRequest> = {}): ToolCallRequest {
return {
call_id: "call_state_001",
tool: "choice",
title: "Choose a path",
prompt: "Select one option.",
data: { source: "test" },
...overrides,
};
}
describe("ToolCallStateMachine", () => {
it("creates one pending record per call_id and preserves the first request", () => {
const state = new ToolCallStateMachine();
const first = state.receive(request(), "conversation_001", RECEIVED_AT);
const duplicate = state.receive(request({ title: "Replacement must be ignored" }), "conversation_002", "2026-08-03T08:01:00.000Z");
expect(first).toMatchObject({ disposition: "accepted", record: { status: "pending", conversation_id: "conversation_001" } });
expect(duplicate).toMatchObject({ disposition: "duplicate", record: { request: { title: "Choose a path" } } });
expect(state.snapshot()).toHaveLength(1);
});
it("permits only pending to submitted to a final result", () => {
const state = new ToolCallStateMachine();
state.receive(request(), "conversation_001", RECEIVED_AT);
expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:01:00.000Z"))
.toMatchObject({ disposition: "invalid_transition", record: { status: "pending" } });
expect(state.submit("call_state_001", "2026-08-03T08:02:00.000Z"))
.toMatchObject({ disposition: "accepted", record: { status: "submitted" } });
expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:03:00.000Z"))
.toMatchObject({ disposition: "accepted", record: { status: "completed", result: { selected: "alpha" } } });
expect(state.submit("call_state_001", "2026-08-03T08:04:00.000Z").disposition).toBe("invalid_transition");
expect(state.submit("missing_call", "2026-08-03T08:04:00.000Z").disposition).toBe("missing");
state.receive(request({ call_id: "call_state_failed" }), "conversation_001", RECEIVED_AT);
state.submit("call_state_failed", "2026-08-03T08:05:00.000Z");
expect(state.complete({ call_id: "call_state_failed", status: "failed", result: {}, error: "Validation rejected" }, "2026-08-03T08:06:00.000Z"))
.toMatchObject({ disposition: "accepted", record: { status: "failed", error: "Validation rejected" } });
});
it("cancels an active request but never changes a terminal record", () => {
const state = new ToolCallStateMachine();
state.receive(request(), "conversation_001", RECEIVED_AT);
expect(state.cancel({ call_id: "call_state_001", reason: "User left the flow" }, "2026-08-03T08:01:00.000Z"))
.toMatchObject({ disposition: "accepted", record: { status: "cancelled", error: "User left the flow" } });
expect(state.cancel({ call_id: "call_state_001", reason: "Again" }, "2026-08-03T08:02:00.000Z").disposition).toBe("invalid_transition");
expect(state.get("missing_call")?.status).toBeUndefined();
});
it("expires only active requests, including one that is already expired on receipt", () => {
const state = new ToolCallStateMachine();
state.receive(request({ call_id: "expired_on_receipt", expires_at: "2026-08-03T07:59:59.000Z" }), "conversation_001", RECEIVED_AT);
state.receive(request({ call_id: "expires_later", expires_at: "2026-08-03T08:05:00.000Z" }), "conversation_001", RECEIVED_AT);
state.receive(request({ call_id: "no_expiry" }), "conversation_001", RECEIVED_AT);
expect(state.get("expired_on_receipt")?.status).toBe("expired");
expect(state.expire("2026-08-03T08:05:00.000Z")).toEqual([
expect.objectContaining({ call_id: "expires_later", status: "expired" }),
]);
expect(state.get("no_expiry")?.status).toBe("pending");
});
});
+116
View File
@@ -0,0 +1,116 @@
import type {
JsonObject,
ToolCallCancel,
ToolCallFinalStatus,
ToolCallRequest,
ToolCallResult,
} from "../protocol";
export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
export type ToolCallRecord = {
call_id: string;
conversation_id: string;
request: ToolCallRequest;
status: ToolCallStatus;
created_at: string;
updated_at: string;
result?: JsonObject;
error?: string;
};
export type ToolCallTransition =
| { disposition: "accepted"; record: ToolCallRecord }
| { disposition: "duplicate" | "invalid_transition" | "missing"; record?: ToolCallRecord };
/**
* Platform-neutral state machine for trusted standard interaction calls.
* It has no UI, persistence, Transport, capability, or side-effect handler;
* later M1 tasks connect those boundaries through this explicit lifecycle.
*/
export class ToolCallStateMachine {
private readonly calls = new Map<string, ToolCallRecord>();
public receive(request: ToolCallRequest, conversationID: string, receivedAt: string): ToolCallTransition {
const existing = this.calls.get(request.call_id);
if (existing) return { disposition: "duplicate", record: copy(existing) };
const expired = request.expires_at !== undefined && Date.parse(request.expires_at) <= Date.parse(receivedAt);
const record: ToolCallRecord = {
call_id: request.call_id,
conversation_id: conversationID,
request: { ...request, data: { ...request.data } },
status: expired ? "expired" : "pending",
created_at: receivedAt,
updated_at: receivedAt,
};
this.calls.set(record.call_id, record);
return { disposition: "accepted", record: copy(record) };
}
public submit(callID: string, updatedAt: string): ToolCallTransition {
return this.transition(callID, updatedAt, ["pending"], record => { record.status = "submitted"; });
}
public complete(result: ToolCallResult, updatedAt: string): ToolCallTransition {
return this.transition(result.call_id, updatedAt, ["submitted"], record => {
record.status = result.status;
record.result = { ...result.result };
record.error = result.error;
});
}
public cancel(cancel: ToolCallCancel, updatedAt: string): ToolCallTransition {
return this.transition(cancel.call_id, updatedAt, ["pending", "submitted"], record => {
record.status = "cancelled";
record.error = cancel.reason || undefined;
});
}
public expire(now: string): readonly ToolCallRecord[] {
const changed: ToolCallRecord[] = [];
const nowTime = Date.parse(now);
for (const record of this.calls.values()) {
if ((record.status === "pending" || record.status === "submitted") && record.request.expires_at && Date.parse(record.request.expires_at) <= nowTime) {
record.status = "expired";
record.updated_at = now;
changed.push(copy(record));
}
}
return changed;
}
public get(callID: string): ToolCallRecord | undefined {
const record = this.calls.get(callID);
return record ? copy(record) : undefined;
}
public snapshot(): readonly ToolCallRecord[] {
return [...this.calls.values()].map(copy);
}
public reset(): void {
this.calls.clear();
}
private transition(
callID: string,
updatedAt: string,
allowed: readonly ToolCallStatus[],
apply: (record: ToolCallRecord) => void,
): ToolCallTransition {
const record = this.calls.get(callID);
if (!record) return { disposition: "missing" };
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
apply(record);
record.updated_at = updatedAt;
return { disposition: "accepted", record: copy(record) };
}
}
function copy(record: ToolCallRecord): ToolCallRecord {
return {
...record,
request: { ...record.request, data: { ...record.request.data } },
...(record.result ? { result: { ...record.result } } : {}),
};
}
@@ -52,6 +52,18 @@ export class TrustedDOMRendererContext {
this.renderSystemMessage(`Agent 错误:${item.message}`);
}
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-02 / M1-03 接入。`);
}
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
this.renderSystemMessage(`收到工具调用结果(${item.result.call_id} · ${item.result.status})。`);
}
public renderToolCancel(item: Extract<ConversationItem, { kind: "tool-cancel" }>): void {
this.renderSystemMessage(`工具调用已取消(${item.cancel.call_id})。`);
}
public renderSurface(item: Extract<ConversationItem, { kind: "surface" }>): void {
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
}