171 lines
6.9 KiB
TypeScript
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 } } : {}),
|
|
};
|
|
}
|