Files
app/tauri/src/runtime/coordination/tool-call-state.ts
T

171 lines
6.9 KiB
TypeScript

/**
* 标准 Tool Call 的纯状态机。
*
* `call_id` 是唯一关联键,只允许声明性 choice、confirm、input 在合法生命周期内迁移;
* 本模块不渲染、不写 Store、不联网,实际结果发送由 Runtime Action/outbox 完成。
*/
import type {
JsonObject,
ToolCallCancel,
ToolCallFinalStatus,
ToolCallRequest,
ToolCallResult,
} from "@/runtime/protocol/lineup-v1";
export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
export type ToolCallRecord = {
call_id: string;
conversation_id: string;
/** Runtime binding for ordinary MiniApp Tools; absent for Interact calls. */
app_scope?: string;
instance_id?: string;
request: ToolCallRequest;
status: ToolCallStatus;
created_at: string;
updated_at: string;
result?: JsonObject;
submission?: 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, submission: JsonObject = {}): ToolCallTransition {
const record = this.calls.get(callID);
if (record?.request.expires_at && Date.parse(record.request.expires_at) <= Date.parse(updatedAt) && (record.status === "pending" || record.status === "submitted")) {
record.status = "expired";
record.updated_at = updatedAt;
return { disposition: "invalid_transition", record: copy(record) };
}
return this.transition(callID, updatedAt, ["pending"], record => {
record.status = "submitted";
record.submission = { ...submission };
});
}
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 expireCall(callID: string, now: string): ToolCallTransition {
const record = this.calls.get(callID);
if (!record) return { disposition: "missing" };
if ((record.status !== "pending" && record.status !== "submitted") || !record.request.expires_at || Date.parse(record.request.expires_at) > Date.parse(now)) {
return { disposition: "invalid_transition", record: copy(record) };
}
record.status = "expired";
record.updated_at = now;
return { disposition: "accepted", record: copy(record) };
}
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();
}
/** Restores trusted local cache records for the current conversation only. */
public restore(records: readonly ToolCallRecord[], now: string): void {
this.calls.clear();
for (const record of records) {
if (!isRestoreableRecord(record) || this.calls.has(record.call_id)) continue;
const restored = copy(record);
if ((restored.status === "pending" || restored.status === "submitted") && restored.request.expires_at && Date.parse(restored.request.expires_at) <= Date.parse(now)) {
restored.status = "expired";
restored.updated_at = now;
}
this.calls.set(restored.call_id, restored);
}
}
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 isRestoreableRecord(value: unknown): value is ToolCallRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Partial<ToolCallRecord>;
if (typeof record.call_id !== "string" || !record.call_id || typeof record.conversation_id !== "string" || !record.conversation_id || !record.request || typeof record.request !== "object") return false;
if (record.request.call_id !== record.call_id || !["choice", "confirm", "input"].includes(record.request.tool) || !record.request.data || typeof record.request.data !== "object" || Array.isArray(record.request.data)) return false;
if (!["pending", "submitted", "completed", "failed", "cancelled", "expired"].includes(record.status ?? "") || typeof record.created_at !== "string" || typeof record.updated_at !== "string") return false;
return !Number.isNaN(Date.parse(record.created_at)) && !Number.isNaN(Date.parse(record.updated_at));
}
function copy(record: ToolCallRecord): ToolCallRecord {
return {
...record,
request: { ...record.request, data: { ...record.request.data } },
...(record.result ? { result: { ...record.result } } : {}),
...(record.submission ? { submission: { ...record.submission } } : {}),
};
}