4e247c112e
Enumerate evidence kinds so education cannot be proposed as a kind, and stream Chinese thinking on a separate channel that collapses when the reply arrives. Co-authored-by: Cursor <cursoragent@cursor.com>
427 lines
18 KiB
TypeScript
427 lines
18 KiB
TypeScript
import {
|
|
appendConsultationRuntimeStep,
|
|
type ConsultationRuntimeState,
|
|
} from "../mastra/consultation-tools.ts";
|
|
import {
|
|
agentExecutionReceiptSchema,
|
|
consultationAgentPublicEventSchema,
|
|
publicActivityPhaseSchema,
|
|
type AgentExecutionReceipt,
|
|
type ConsultationAgentPublicEvent,
|
|
} from "./consultation-agent-events.ts";
|
|
import { toAgentModelFinishReason } from "./agent-observability.ts";
|
|
import { createVisibleTextTransformer } from "./stream-text-response.ts";
|
|
import { sanitizePublicThinkingText } from "./public-thinking.ts";
|
|
|
|
type Chunk = { type?: string; payload?: Record<string, unknown>; data?: unknown };
|
|
type ChunkStream = AsyncIterable<unknown> | ReadableStream<unknown>;
|
|
|
|
async function* readChunks(stream: ChunkStream): AsyncIterable<Chunk> {
|
|
const values = Symbol.asyncIterator in stream
|
|
? stream as AsyncIterable<unknown>
|
|
: (async function* () {
|
|
const reader = (stream as ReadableStream<unknown>).getReader();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return;
|
|
yield value;
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
})();
|
|
for await (const value of values) {
|
|
if (value && typeof value === "object") yield value as Chunk;
|
|
}
|
|
}
|
|
type Status = "ready" | "degraded" | "blocked";
|
|
|
|
type EventOptions = {
|
|
runId: string;
|
|
requestId: string;
|
|
toolStatus: () => Status;
|
|
receipt: () => AgentExecutionReceipt;
|
|
state?: ConsultationRuntimeState;
|
|
};
|
|
|
|
function activity(value: unknown): ConsultationAgentPublicEvent | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const data = value as { phase?: unknown; label?: unknown };
|
|
const phase = publicActivityPhaseSchema.safeParse(data.phase);
|
|
if (!phase.success || typeof data.label !== "string") return null;
|
|
return { type: "activity", phase: phase.data, label: data.label.slice(0, 120) };
|
|
}
|
|
|
|
/**
|
|
* The runtime only reveals how the model loop ended through the stream: one
|
|
* `step-finish` per model step, then a terminal `finish` carrying the reason
|
|
* the model stopped and the authoritative step list. Without this, a run that
|
|
* exhausted its step budget is indistinguishable from one that chose to stop,
|
|
* because progressive-disclosure reads never reach the public event stream.
|
|
*/
|
|
function finishTelemetry(chunk: Chunk) {
|
|
const payload = chunk.payload as {
|
|
stepResult?: { reason?: unknown };
|
|
output?: { steps?: unknown };
|
|
} | undefined;
|
|
const steps = payload?.output?.steps;
|
|
return {
|
|
reason: toAgentModelFinishReason(payload?.stepResult?.reason),
|
|
stepCount: Array.isArray(steps) ? steps.length : null,
|
|
};
|
|
}
|
|
|
|
function safeToolError(error: unknown) {
|
|
if (error instanceof DOMException && error.name === "AbortError") return "cancelled" as const;
|
|
if (error instanceof DOMException && error.name === "TimeoutError") return "timeout" as const;
|
|
return "calculation_failed" as const;
|
|
}
|
|
|
|
function isTimeoutOrAbort(error: unknown) {
|
|
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
}
|
|
|
|
type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed";
|
|
|
|
function runFailedCode(error: unknown, emitted: boolean): RunFailedCode {
|
|
if (error instanceof Error && error.message === "runtime_contract_incomplete") return "runtime_contract_incomplete";
|
|
if (error instanceof Error && error.message === "empty_answer") return "empty_answer";
|
|
if (error instanceof Error && error.message === "answer_truncated") return "answer_truncated";
|
|
if (emitted && isTimeoutOrAbort(error)) return "answer_truncated";
|
|
return "calculation_failed";
|
|
}
|
|
|
|
function runFailedMessage(code: RunFailedCode) {
|
|
if (code === "runtime_contract_incomplete") return "Agent 未完成必要的方法与计算步骤,本次不会扣点。";
|
|
if (code === "empty_answer") return "计算已完成,但这次没有生成回答,本次不会扣点。请再发送一次。";
|
|
if (code === "answer_truncated") return "回答未完成,已保留现有内容;本次不会扣点。";
|
|
return "咨询暂时无法完成,本次不会扣点。";
|
|
}
|
|
|
|
/**
|
|
* Whether a tool result is really an input rejection Mastra resolved with.
|
|
*
|
|
* Mastra validates arguments against the tool's inputSchema before `execute`
|
|
* and reports a mismatch by *resolving* with an error envelope rather than
|
|
* throwing. Passing that through as `tool.completed` would tell the client a
|
|
* calculation finished while the tool body never ran. Matched on the envelope's
|
|
* shape, not its English message, so an upstream wording change cannot silently
|
|
* turn a rejection back into a success.
|
|
*/
|
|
function isToolInputRejection(result: unknown) {
|
|
if (!result || typeof result !== "object") return false;
|
|
const envelope = result as { error?: unknown; validationErrors?: unknown };
|
|
return envelope.error === true
|
|
&& typeof envelope.validationErrors === "object"
|
|
&& envelope.validationErrors !== null;
|
|
}
|
|
|
|
/**
|
|
* Record a tool failure the tool itself could not record.
|
|
*
|
|
* A call rejected against the tool's input schema never enters `execute`, so
|
|
* nothing in the tool runs: the run reported a `tool.failed` event to the client
|
|
* while its receipt showed no failed step and its budget counted no call. The
|
|
* stream is the only place that observes every failure, whichever side of the
|
|
* schema it came from.
|
|
*
|
|
* The tool still records the failures it can see, with the duration and cause it
|
|
* alone knows, so this only fills the gap: it appends when the stream has seen
|
|
* more tool errors than the state has failed tool steps. The error object here
|
|
* is provider-shaped and cannot be classified further, but the call not reaching
|
|
* `execute` is itself the diagnosis.
|
|
*/
|
|
function recordUnrecordedToolFailure(
|
|
state: ConsultationRuntimeState,
|
|
toolErrorsSeen: number,
|
|
durationMs: number,
|
|
) {
|
|
const recorded = state.steps.filter((step) => step.kind === "tool" && step.status === "failed").length;
|
|
if (recorded >= toolErrorsSeen) return;
|
|
appendConsultationRuntimeStep(state, {
|
|
kind: "tool",
|
|
name: "run-jyotish-consultation",
|
|
status: "failed",
|
|
durationMs,
|
|
failureCode: "tool_call_rejected",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The two events that used to report the model activating the skill. The server
|
|
* binds the method into the instructions now, so they are announced once at the
|
|
* start of a run instead of being read off a tool call that no longer happens.
|
|
* They stay in the public stream because they are what tells a waiting client
|
|
* that method is in hand, and the first of them is a run's first activity.
|
|
*/
|
|
const skillBoundEvents: readonly ConsultationAgentPublicEvent[] = [
|
|
{ type: "skill.started", name: "jyotish-vedic-astrology" },
|
|
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
|
|
];
|
|
|
|
function mapChunk(
|
|
chunk: Chunk,
|
|
options: EventOptions,
|
|
startedAt: Map<string, number>,
|
|
toolErrors: { seen: number },
|
|
): ConsultationAgentPublicEvent[] {
|
|
const payload = chunk.payload ?? {};
|
|
if (chunk.type === "data-jyotish-activity") {
|
|
const event = activity(chunk.data);
|
|
return event ? [event] : [];
|
|
}
|
|
if (chunk.type === "tool-call") {
|
|
const toolName = payload.toolName;
|
|
const callId = typeof payload.toolCallId === "string" ? payload.toolCallId : "tool";
|
|
if (toolName === "run-jyotish-consultation") {
|
|
startedAt.set(callId, Date.now());
|
|
return [{ type: "tool.started", callId, tool: "run-jyotish-consultation", label: "正在计算个人星盘" }];
|
|
}
|
|
}
|
|
if (chunk.type === "tool-result") {
|
|
const toolName = payload.toolName;
|
|
const callId = typeof payload.toolCallId === "string" ? payload.toolCallId : "tool";
|
|
if (toolName === "run-jyotish-consultation") {
|
|
const durationMs = Math.max(0, Date.now() - (startedAt.get(callId) ?? Date.now()));
|
|
if (isToolInputRejection(payload.result)) {
|
|
toolErrors.seen += 1;
|
|
if (options.state) recordUnrecordedToolFailure(options.state, toolErrors.seen, durationMs);
|
|
return [{ type: "tool.failed", callId, tool: "run-jyotish-consultation", code: "calculation_failed" }];
|
|
}
|
|
return [{
|
|
type: "tool.completed", callId, tool: "run-jyotish-consultation", status: options.toolStatus(),
|
|
durationMs,
|
|
}];
|
|
}
|
|
}
|
|
if (chunk.type === "tool-error") {
|
|
const toolName = payload.toolName;
|
|
if (toolName === "run-jyotish-consultation") {
|
|
const callId = typeof payload.toolCallId === "string" ? payload.toolCallId : "tool";
|
|
toolErrors.seen += 1;
|
|
if (options.state) {
|
|
recordUnrecordedToolFailure(
|
|
options.state,
|
|
toolErrors.seen,
|
|
Math.max(0, Date.now() - (startedAt.get(callId) ?? Date.now())),
|
|
);
|
|
}
|
|
return [{
|
|
type: "tool.failed",
|
|
callId,
|
|
tool: "run-jyotish-consultation",
|
|
code: safeToolError(payload.error),
|
|
}];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Chunk>, options: EventOptions) {
|
|
const events: ConsultationAgentPublicEvent[] = [
|
|
{ type: "run.started", runId: options.runId, requestId: options.requestId },
|
|
...skillBoundEvents,
|
|
];
|
|
const startedAt = new Map<string, number>();
|
|
const toolErrors = { seen: 0 };
|
|
for await (const chunk of stream instanceof ReadableStream || Symbol.asyncIterator in stream ? readChunks(stream as ChunkStream) : stream) {
|
|
events.push(...mapChunk(chunk, options, startedAt, toolErrors));
|
|
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
|
|
events.push({ type: "answer.delta", text: chunk.payload.text });
|
|
}
|
|
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
|
|
const thinking = sanitizePublicThinkingText(chunk.payload.text);
|
|
if (thinking) events.push({ type: "thinking.delta", text: thinking });
|
|
}
|
|
}
|
|
events.push({ type: "run.completed", receipt: agentExecutionReceiptSchema.parse(options.receipt()) });
|
|
return events.map((event) => consultationAgentPublicEventSchema.parse(event));
|
|
}
|
|
|
|
type StreamAgentResponseOptions = EventOptions & {
|
|
state: ConsultationRuntimeState;
|
|
stream: ChunkStream;
|
|
transformText?: (text: string) => string;
|
|
requireTool: boolean;
|
|
retry?: () => Promise<ChunkStream>;
|
|
retryForAnswer?: () => Promise<ChunkStream>;
|
|
continueAfterDisconnect?: boolean;
|
|
headers?: HeadersInit;
|
|
onFirstActivity?: () => void | Promise<void>;
|
|
onFirstOutput?: () => void | Promise<void>;
|
|
onComplete?: (output: string, receipt: AgentExecutionReceipt) => void | Promise<void>;
|
|
onError?: (error: unknown, emitted: boolean, output: string) => void | Promise<void>;
|
|
onCancel?: (emitted: boolean) => void | Promise<void>;
|
|
};
|
|
|
|
// Failed attempts are retried by the model against the same request-scoped
|
|
// calculation cache, so only successful workflow executions may count against
|
|
// the single-calculation boundary. Gating on total attempts would make any
|
|
// transient failure permanently unrecoverable.
|
|
function contractReady(options: StreamAgentResponseOptions) {
|
|
return options.state.jyotishSkillBound
|
|
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
|
|
}
|
|
|
|
export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
|
const encoder = new TextEncoder();
|
|
let disconnected = false;
|
|
let settled = false;
|
|
let settling = false;
|
|
let emitted = false;
|
|
let firstActivity = false;
|
|
let firstOutput = false;
|
|
let fullOutput = "";
|
|
const startedAt = new Map<string, number>();
|
|
// A retry reuses these counters so a failure in either attempt is recorded once.
|
|
const toolErrors = { seen: 0 };
|
|
const send = (controller: ReadableStreamDefaultController<Uint8Array> | undefined, event: ConsultationAgentPublicEvent) => {
|
|
if (!firstActivity && (event.type === "skill.started" || event.type === "tool.started" || event.type === "activity")) {
|
|
firstActivity = true;
|
|
void Promise.resolve(options.onFirstActivity?.()).catch(() => {});
|
|
}
|
|
if (!disconnected && controller) controller.enqueue(encoder.encode(`${JSON.stringify(consultationAgentPublicEventSchema.parse(event))}\n`));
|
|
};
|
|
|
|
async function consumeAttempt(controller: ReadableStreamDefaultController<Uint8Array> | undefined, stream: ChunkStream) {
|
|
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
|
|
let held = "";
|
|
let composingSent = false;
|
|
const outputText = async (text: string) => {
|
|
// Text the model writes before the contract is ready is not the answer: it
|
|
// is the model narrating its own in-progress or failed tool calls. Holding
|
|
// it meant a later successful call released that narration as the entire
|
|
// visible answer, so a run where the model recovered read as a run where it
|
|
// explained itself instead of answering. Drop it.
|
|
if (!contractReady(options)) return;
|
|
held += text;
|
|
if (!held) return;
|
|
if (!composingSent) {
|
|
composingSent = true;
|
|
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
|
|
}
|
|
if (!firstOutput && /\S/.test(held)) {
|
|
firstOutput = true;
|
|
await options.onFirstOutput?.();
|
|
}
|
|
send(controller, { type: "answer.delta", text: held });
|
|
fullOutput += held;
|
|
if (/\S/.test(held)) emitted = true;
|
|
held = "";
|
|
};
|
|
// A retry runs a second model loop under the same step budget, so the run
|
|
// total accumulates while the finish reason describes the latest attempt.
|
|
const stepCountBeforeAttempt = options.state.modelStepCount;
|
|
try {
|
|
for await (const chunk of readChunks(stream)) {
|
|
for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event);
|
|
if (chunk.type === "step-finish") options.state.modelStepCount += 1;
|
|
if (chunk.type === "finish") {
|
|
const finish = finishTelemetry(chunk);
|
|
options.state.modelFinishReason = finish.reason;
|
|
if (finish.stepCount !== null) options.state.modelStepCount = stepCountBeforeAttempt + finish.stepCount;
|
|
}
|
|
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
|
|
await outputText(visible.push(chunk.payload.text));
|
|
}
|
|
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
|
|
const thinking = sanitizePublicThinkingText(chunk.payload.text);
|
|
if (thinking) send(controller, { type: "thinking.delta", text: thinking });
|
|
}
|
|
}
|
|
await outputText(visible.finish(""));
|
|
} catch (error) {
|
|
try {
|
|
await outputText(visible.finish(""));
|
|
} catch {}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
void (async () => {
|
|
send(controller, { type: "run.started", runId: options.runId, requestId: options.requestId });
|
|
for (const event of skillBoundEvents) send(controller, event);
|
|
try {
|
|
await consumeAttempt(controller, options.stream);
|
|
if (!contractReady(options) && options.retry) {
|
|
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" });
|
|
send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" });
|
|
await consumeAttempt(controller, await options.retry());
|
|
}
|
|
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
|
|
// A run whose calculation succeeded and whose model then wrote nothing
|
|
// used to be answered with a fixed apology and billed as a completed
|
|
// consultation: the user paid for a sentence saying there was nothing
|
|
// to say. Ask once more instead. The calculation is cached for the
|
|
// request, so the second attempt re-reads the same evidence without
|
|
// recomputing it, and whatever ended the first attempt—an exhausted
|
|
// step budget above all—does not carry into a fresh model loop.
|
|
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
|
|
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
|
|
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
|
|
await consumeAttempt(controller, await options.retryForAnswer());
|
|
}
|
|
// Still nothing to show. Failing is the honest outcome and it is the
|
|
// one that does not charge for the run.
|
|
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
|
|
// A spoken answer that stopped because the token budget ran out is
|
|
// not a completed consultation. The heading may already be on screen,
|
|
// so keep it and refuse to bill.
|
|
if (options.state.modelFinishReason === "length") throw new Error("answer_truncated");
|
|
settling = true;
|
|
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
|
|
await options.onComplete?.(fullOutput, receipt);
|
|
settled = true;
|
|
settling = false;
|
|
send(controller, { type: "run.completed", receipt });
|
|
if (!disconnected) controller.close();
|
|
} catch (error) {
|
|
if (settled) return;
|
|
settled = true;
|
|
settling = false;
|
|
try {
|
|
await options.onError?.(error, emitted, fullOutput);
|
|
} catch {}
|
|
const code = runFailedCode(error, emitted);
|
|
// Step durations, the step budget and the workflow route are the only
|
|
// evidence the caller has for why a run failed. Building the receipt
|
|
// must not be able to replace the failure event with a silent close.
|
|
let failureReceipt: AgentExecutionReceipt | undefined;
|
|
try {
|
|
failureReceipt = agentExecutionReceiptSchema.parse(options.receipt());
|
|
} catch {}
|
|
send(controller, {
|
|
type: "run.failed",
|
|
code,
|
|
message: runFailedMessage(code),
|
|
...(failureReceipt ? { receipt: failureReceipt } : {}),
|
|
});
|
|
if (!disconnected) controller.close();
|
|
}
|
|
})();
|
|
},
|
|
async cancel() {
|
|
if (settled) return;
|
|
if (settling || options.continueAfterDisconnect) {
|
|
disconnected = true;
|
|
return;
|
|
}
|
|
settled = true;
|
|
await options.onCancel?.(emitted);
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
headers: {
|
|
"cache-control": "no-cache, no-transform",
|
|
"content-type": "application/x-ndjson; charset=utf-8",
|
|
"x-accel-buffering": "no",
|
|
"x-ayanam-mode": "mastra-agentic",
|
|
"x-ayanam-request-id": options.requestId,
|
|
...options.headers,
|
|
},
|
|
});
|
|
}
|