fix(web): show live agent work progress and fail truncated rectification answers

Rectification dropped tool.activity started events and treated length finishes as completed. Share generation settings with consultation, keep the activity line through streaming, and name multi-domain chart calculation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 12:39:09 +08:00
co-authored by Cursor
parent 036a756be3
commit 6a44c778c3
21 changed files with 539 additions and 81 deletions
@@ -290,6 +290,7 @@ export async function POST(request: Request) {
emit: (event) => send(event),
signal: request.signal,
timeContext,
generationModel: selectedModel.model,
buildAgent: (turnId, skillPackage, attemptId) => Promise.resolve(
getRectificationV9Agent(selectedModel, {
userId,
+22 -1
View File
@@ -241,8 +241,29 @@ button:disabled { cursor: default; opacity: .45; }
}
.markdown-table tr:last-child th,
.markdown-table tr:last-child td { border-bottom: 0; }
.agent-activity-status { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-body-sm); line-height: 1.5; }
.agent-activity-status {
min-height: 24px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
line-height: 1.5;
}
.agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; }
.agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
.agent-activity-status canvas { flex: 0 0 auto; }
.agent-activity-status__elapsed {
flex: 0 0 auto;
color: var(--color-ink-tertiary);
font-variant-numeric: tabular-nums;
}
.agent-activity-status__trail {
margin: 0;
padding-left: 28px;
color: var(--color-ink-tertiary);
}
.agent-activity-status__text {
color: var(--color-ink-tertiary);
background: linear-gradient(
+36 -8
View File
@@ -92,12 +92,20 @@ import {
BALANCE_CHANGED_EVENT,
membershipHref,
} from "@/lib/membership";
import { chatMessageViews, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { chatMessageViews, nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import {
createNdjsonParser,
type AgentExecutionReceipt,
type ConsultationAgentPublicEvent,
} from "@/lib/consultation-agent-events";
import {
CONSULTATION_CHART_CALCULATION_LABEL,
CONSULTATION_COMPOSING_LABEL,
CONSULTATION_DONE_CHART_LABEL,
CONSULTATION_DONE_SKILL_LABEL,
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
} from "@/lib/consultation-activity-labels";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
@@ -3200,7 +3208,13 @@ export default function Home() {
const updateStreamingAnswer = (activity?: AgentActivityView) => {
const partialReply = parseAgentReply(answer).text;
latestPartialReply = partialReply;
setStreamingReply({ sessionId, text: partialReply, activity });
setStreamingReply((current) => ({
sessionId,
text: partialReply,
activity: activity
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
: current?.sessionId === sessionId ? current.activity : undefined,
}));
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
}
@@ -3208,15 +3222,29 @@ export default function Home() {
const updateActivity = (event: ConsultationAgentPublicEvent) => {
let activity: AgentActivityView | undefined;
if (event.type === "skill.started") {
activity = { phase: "loading-method", label: "正在读取印度占星分析规则…" };
} else if (event.type === "tool.started") {
activity = { phase: "chart-calculation", label: "正在计算本命盘…" };
activity = { phase: "loading-method", label: CONSULTATION_LOADING_METHOD_LABEL };
} else if (event.type === "skill.completed" || event.type === "tool.started") {
activity = {
phase: "chart-calculation",
label: CONSULTATION_CHART_CALCULATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "activity") {
activity = { phase: event.phase, label: event.label };
activity = {
phase: event.phase,
label: event.label,
completedTrail: event.phase === "evidence-validation"
? activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL])
: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "tool.completed") {
activity = { phase: "evidence-validation", label: "正在核对可用证据…" };
activity = {
phase: "evidence-validation",
label: CONSULTATION_EVIDENCE_VALIDATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]),
};
} else if (event.type === "answer.delta") {
activity = { phase: "answer-composition", label: "正在组织回答…" };
activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL };
}
if (activity) updateStreamingAnswer(activity);
};
@@ -1,9 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityElapsedLabel } from "@/lib/chat-message-view";
const labels = {
working: "正在处理任务…",
@@ -27,17 +29,40 @@ prefetchOnIdle(importThinkingOrb);
export type AgentActivityState = OrbState;
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const label = activityElapsedLabel(startedAt, now);
if (!label) return null;
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
}
export function AgentActivityStatus({
state,
label = labels[state],
startedAt,
completedTrail,
}: Readonly<{
state: AgentActivityState;
label?: string;
startedAt?: number;
completedTrail?: string;
}>) {
return (
<div className="agent-activity-status" role="status">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
<span key={label} className="agent-activity-status__text">{label}</span>
<div className="agent-activity-status">
<div className="agent-activity-status__row">
<div className="agent-activity-status__live" role="status">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
<span key={label} className="agent-activity-status__text">{label}</span>
</div>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</div>
{completedTrail ? (
<p className="agent-activity-status__trail" aria-hidden="true">{completedTrail}</p>
) : null}
</div>
);
}
+6 -1
View File
@@ -97,7 +97,12 @@ export function ChatMessageRow({
{message.role === "assistant" ? (
<>
{showActivity && (
<AgentActivityStatus state={activityState} label={activityLabel} />
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
/>
)}
{message.text && (
<ChatMessageContent
@@ -2,27 +2,14 @@
import { Check, ChevronDown } from "lucide-react";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import { RECTIFICATION_TOOL_DONE_LABELS } from "@/lib/rectification-activity-labels";
import type {
PublicRectificationMethod,
PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "@/lib/rectification-varga-sentence";
const TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
"rectification-set-focus": "设置对话焦点",
"rectification-resolve-focus": "处理当前焦点",
"rectification-record-evidence-batch": "整理多条事件证据",
"rectification-propose-evidence": "整理事件证据",
"rectification-confirm-evidence": "确认事件证据",
"rectification-revise-evidence": "修订事件证据",
"rectification-compare-candidates": "比较候选时间",
"rectification-read-diagnostics": "检查候选稳健性",
"rectification-offer-candidates": "生成候选建议",
"rectification-accept-candidate": "采用候选时间",
"rectification-confirm-birth-time": "确认校正时间",
"rectification-close-case": "完成校正记录",
};
const TOOL_LABELS = RECTIFICATION_TOOL_DONE_LABELS;
const METHOD_LABELS = PUBLIC_RECTIFICATION_METHOD_LABELS;
@@ -4,7 +4,12 @@ import { ArrowUp, Square } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
@@ -348,7 +353,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
} },
]);
setDraft("");
@@ -426,7 +435,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
raw += event.text;
const parsed = parseAgentReply(raw);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "streaming" }
? {
...message,
text: parsed.text,
state: "streaming",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message));
} else if (event.type === "attempt.reset") {
raw = "";
@@ -434,7 +451,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined }
? {
...message,
text: "",
state: "thinking",
completedReceipt: undefined,
failed: false,
turnId: undefined,
activity: nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
}),
}
: message));
} else if (event.type === "run.failed") {
streamFailed = true;
@@ -447,7 +475,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} else if (event.type === "tool.activity") {
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
if (!tool) continue;
if (event.status === "started") continue;
if (event.status === "started") {
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
}),
}
: message));
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
@@ -473,20 +513,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt,
failed: false,
turnId: completedTurnId,
activity: undefined,
}];
}
if (streamFailed || hasActivityReceipt(completedReceipt)) {
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
return [{
...message,
text: "",
text: parsed.text,
state: "settled",
completedReceipt,
failed: true,
activity: undefined,
}];
}
return [];
}));
if (!succeeded && completedReceipt.failedTool) {
if (!succeeded && parsed.text) {
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
} else if (!succeeded && completedReceipt.failedTool) {
setError(completedReceipt.failedTool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留。"
: "本轮处理未完成,当前进度已保留。请稍后再试。");
@@ -719,7 +763,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !readonly
&& regeneratingMessageKey === null;
const displayedMessage = regenerating
? { ...message, text: "", state: "thinking" as const }
? {
...message,
text: "",
state: "thinking" as const,
activity: nextActivityView(undefined, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message;
const vargaSentence = message.state === "settled" && !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
@@ -728,13 +780,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{message.state === "settled" && message.failed && (
<p className="rectification-activity-failure" role="status">
{message.text
? "回答未完成,已保留现有内容;本次不会扣点。"
: "本轮处理未完成,已保留服务端记录的执行进度。"}
</p>
)}
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state === "thinking"}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
/>
)}
@@ -0,0 +1,28 @@
/**
* Visible-answer generation settings shared by consultation and rectification.
*
* DeepSeek V4 Flash thinks by default, and those hidden tokens share
* `max_tokens` with the spoken answer. Without an explicit visible budget and
* thinking turned off, a finished-looking stream can stop mid-heading with
* `finish_reason=length`. The provider id is repeated under `openai` because
* OpenAI-compatible adapters often look there first.
*/
export const AGENT_MAX_OUTPUT_TOKENS = 8192;
const thinkingDisabled = { thinking: { type: "disabled" as const } };
export function agentGenerationSettings(model?: unknown) {
const providerId = typeof model === "string"
? model
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
? model.providerId
: undefined;
const providerOptions: Record<string, typeof thinkingDisabled> = {
openai: thinkingDisabled,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
return {
modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
providerOptions,
};
}
+35
View File
@@ -1,10 +1,45 @@
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
export const ACTIVITY_ELAPSED_VISIBLE_AFTER_MS = 8_000;
export const ACTIVITY_COMPLETED_TRAIL_LIMIT = 3;
export type AgentActivityView = Readonly<{
phase: PublicActivityPhase;
label: string;
startedAt?: number;
completedTrail?: string;
}>;
export function activityCompletedTrail(steps: readonly string[]): string | undefined {
if (steps.length === 0) return undefined;
return `已完成:${steps.slice(-ACTIVITY_COMPLETED_TRAIL_LIMIT).join(" · ")}`;
}
export function nextActivityView(
previous: AgentActivityView | undefined,
next: Omit<AgentActivityView, "startedAt">,
now = Date.now(),
): AgentActivityView {
const startedAt = previous?.label === next.label && previous.startedAt ? previous.startedAt : now;
const completedTrail = next.phase === "answer-composition"
? undefined
: next.completedTrail !== undefined
? next.completedTrail || undefined
: previous?.completedTrail;
return {
phase: next.phase,
label: next.label,
startedAt,
...(completedTrail ? { completedTrail } : {}),
};
}
export function activityElapsedLabel(startedAt: number, now: number): string | null {
const elapsedMs = now - startedAt;
if (elapsedMs < ACTIVITY_ELAPSED_VISIBLE_AFTER_MS) return null;
return `已用时 ${Math.floor(elapsedMs / 1000)}`;
}
export type ChatMessage = {
readonly role: "user" | "assistant";
readonly text: string;
@@ -0,0 +1,11 @@
export const CONSULTATION_LOADING_METHOD_LABEL = "正在读取印度占星分析规则…";
export const CONSULTATION_CHART_CALCULATION_LABEL = "正在计算本命盘…";
export const CONSULTATION_EVIDENCE_VALIDATION_LABEL = "正在核对可用证据…";
export const CONSULTATION_COMPOSING_LABEL = "正在组织回答…";
export const CONSULTATION_DONE_SKILL_LABEL = "读取分析方法";
export const CONSULTATION_DONE_CHART_LABEL = "计算本命盘";
export function chartCalculationProgressLabel(current: number, total: number): string {
if (total <= 1) return CONSULTATION_CHART_CALCULATION_LABEL;
return `正在计算本命盘(第 ${current}/${total} 项)…`;
}
@@ -0,0 +1,64 @@
import { activityCompletedTrail } from "./chat-message-view.ts";
import type { PublicActivityPhase } from "./consultation-agent-events.ts";
import type { PublicRectificationTool } from "./rectification-agentic/v9/public-receipt.ts";
export const RECTIFICATION_TOOL_DONE_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
"rectification-set-focus": "设置对话焦点",
"rectification-resolve-focus": "处理当前焦点",
"rectification-record-evidence-batch": "整理多条事件证据",
"rectification-propose-evidence": "整理事件证据",
"rectification-confirm-evidence": "确认事件证据",
"rectification-revise-evidence": "修订事件证据",
"rectification-compare-candidates": "比较候选时间",
"rectification-read-diagnostics": "检查候选稳健性",
"rectification-offer-candidates": "生成候选建议",
"rectification-accept-candidate": "采用候选时间",
"rectification-confirm-birth-time": "确认校正时间",
"rectification-close-case": "完成校正记录",
};
export const RECTIFICATION_TOOL_PROGRESS_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "正在读取校正记录…",
"rectification-set-focus": "正在设置对话焦点…",
"rectification-resolve-focus": "正在处理当前焦点…",
"rectification-record-evidence-batch": "正在整理多条事件证据…",
"rectification-propose-evidence": "正在整理事件证据…",
"rectification-confirm-evidence": "正在确认事件证据…",
"rectification-revise-evidence": "正在修订事件证据…",
"rectification-compare-candidates": "正在比较候选时间…",
"rectification-read-diagnostics": "正在检查候选稳健性…",
"rectification-offer-candidates": "正在生成候选建议…",
"rectification-accept-candidate": "正在采用候选时间…",
"rectification-confirm-birth-time": "正在确认校正时间…",
"rectification-close-case": "正在完成校正记录…",
};
const COMPARE_TOOLS = new Set<PublicRectificationTool>([
"rectification-compare-candidates",
"rectification-read-diagnostics",
"rectification-offer-candidates",
]);
const LOAD_TOOLS = new Set<PublicRectificationTool>([
"rectification-read-case",
"rectification-set-focus",
"rectification-resolve-focus",
]);
export function rectificationCompletedTrail(steps: readonly PublicRectificationTool[]): string | undefined {
return activityCompletedTrail(steps.map((tool) => RECTIFICATION_TOOL_DONE_LABELS[tool]));
}
export function rectificationToolActivityPhase(tool: PublicRectificationTool): PublicActivityPhase {
if (LOAD_TOOLS.has(tool)) return "loading-method";
if (COMPARE_TOOLS.has(tool)) return "chart-calculation";
if (
tool === "rectification-accept-candidate"
|| tool === "rectification-confirm-birth-time"
|| tool === "rectification-close-case"
) {
return "answer-composition";
}
return "evidence-validation";
}
@@ -20,6 +20,8 @@ import {
type V9CaseDossier,
} from "./tool-service";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status";
import { agentGenerationSettings } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -58,6 +60,7 @@ export type V9AgentRunOptions = Readonly<{
emit(event: PublicStreamEvent): Promise<void> | void;
signal?: AbortSignal;
timeContext?: string;
generationModel?: unknown;
}>;
export type V9AgentRunResult = Readonly<{
@@ -100,6 +103,14 @@ const RETRYABLE_ERROR_CODES = new Set([
"focus_persistence_failed",
]);
function streamFinishReason(chunk: {
type: string;
payload?: { stepResult?: { reason?: unknown }; reason?: unknown };
}): ReturnType<typeof toAgentModelFinishReason> | null {
if (chunk.type !== "finish") return null;
return toAgentModelFinishReason(chunk.payload?.stepResult?.reason ?? chunk.payload?.reason);
}
function first(value: unknown): unknown {
if (Array.isArray(value)) return value[0] ?? null;
if (value && typeof value === "object" && "value" in value) {
@@ -129,6 +140,7 @@ function safeErrorCode(error: unknown): string {
"case_not_loaded",
"repeated_tool_call",
"focus_persistence_failed",
"answer_truncated",
]) {
if (message.includes(code)) return code;
}
@@ -489,12 +501,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel);
const result = await (agent as unknown as {
stream(
messages: unknown[],
streamOptions: {
maxSteps: number;
abortSignal: AbortSignal;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, { thinking: { type: "disabled" } }>;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
@@ -503,7 +518,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
): Promise<{
fullStream: AsyncIterable<{
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
payload?: {
toolName?: unknown;
text?: unknown;
args?: unknown;
error?: unknown;
stepResult?: { reason?: unknown };
reason?: unknown;
};
object?: unknown;
}>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
@@ -511,6 +533,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
...generation,
// Thinking-mode providers reject named/required tool_choice. Restrict
// the first step to read-case and keep tool_choice auto; the runner
// still refuses any other public tool before case.loaded.
@@ -522,6 +545,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
: undefined,
});
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
for await (const chunk of result.fullStream) {
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
if (chunk.type === "tool-call") {
@@ -592,7 +617,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
for (const toolName of streamToolNames(chunk as never)) toolsUsed.add(toolName);
if (chunk.type === "error" || chunk.type === "abort") streamFailed = true;
if (chunk.type === "finish") finished = true;
if (chunk.type === "finish") {
finished = true;
finishReason = streamFinishReason(chunk);
}
}
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
@@ -600,6 +628,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
if (finishReason === "length") {
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
+15 -30
View File
@@ -13,6 +13,8 @@ import { createConsultationPlan, type ConsultationPlan } from "../lib/consultati
import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.ts";
import type { AgentModelFinishReason } from "../lib/agent-observability.ts";
import { agentGenerationSettings } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel } from "../lib/consultation-activity-labels.ts";
import {
consultationEvidencePacketSchema,
consultationInputSchema,
@@ -24,6 +26,8 @@ import {
type ConsultationEvidencePacket,
} from "./consultation-workflow.ts";
export { AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
// The budgets that bound one consultation run. They all constrain the same
// wall clock, so they are declared together and must be changed together.
//
@@ -42,7 +46,6 @@ import {
// calculation inside the 110s budget—so it is measured, not guessed.
export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192;
const CONSULTATION_DOMAIN_DURATION_MS = 21_000;
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
@@ -51,31 +54,8 @@ export const MAX_CONSULTATION_DOMAINS = Math.max(
Math.floor(CONSULTATION_DOMAIN_WALL_CLOCK_MS / CONSULTATION_DOMAIN_DURATION_MS),
);
const thinkingDisabled = { thinking: { type: "disabled" as const } };
/**
* Visible-answer generation settings for one consult stream.
*
* DeepSeek V4 Flash thinks by default, and those hidden tokens share
* `max_tokens` with the spoken answer. Without an explicit visible budget and
* thinking turned off, a finished-looking stream can stop mid-heading with
* `finish_reason=length`. The provider id is repeated under `openai` because
* OpenAI-compatible adapters often look there first.
*/
export function consultationGenerationSettings(model?: unknown) {
const providerId = typeof model === "string"
? model
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
? model.providerId
: undefined;
const providerOptions: Record<string, typeof thinkingDisabled> = {
openai: thinkingDisabled,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
return {
modelSettings: { maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS },
providerOptions,
};
return agentGenerationSettings(model);
}
// The raw plan bound stays at the registry default so a duplicate-heavy list
@@ -562,16 +542,21 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
const currentCalculation = (async () => {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
await context.writer?.custom({
type: "data-jyotish-activity",
data: { phase: "chart-calculation", label: "正在计算本命盘" },
});
const executions: DomainExecution[] = [];
for (const domain of domains) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1