fix(web): stop consultation thinking from pinching the answer

Disable provider thinking so Flash CoT cannot fill max_tokens, raise the
spoken budget to 16384, emit a server-owned step tree, and continue once
when the body ends on length.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-22 21:19:00 +08:00
co-authored by Cursor
parent d2a5f0f5bf
commit 0ca7da997f
26 changed files with 1013 additions and 88 deletions
+39 -3
View File
@@ -36,6 +36,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { streamAgentResponse } from "@/lib/stream-agent-response";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
import { consultationContinuePrompt, type PublicThinkingSection } from "@/lib/consultation-thinking-plan";
import {
AGENT_MAX_STEPS,
AGENT_TIMEOUT_MS,
@@ -501,6 +502,7 @@ export async function POST(request: Request) {
workflowReceipt: WorkflowReceipt,
agentExecutionReceipt?: AgentExecutionReceipt,
thinkingText?: string,
thinkingSections?: PublicThinkingSection[],
): Promise<AgentSettlementResult> {
try {
const reply = parseAgentReply(
@@ -513,6 +515,7 @@ export async function POST(request: Request) {
role: "assistant" as const,
text: reply.text,
...(persistedThinking ? { thinkingText: persistedThinking } : {}),
...(thinkingSections?.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
...(agentExecutionReceipt ? { agentExecutionReceipt } : {}),
@@ -724,6 +727,15 @@ export async function POST(request: Request) {
usages.push(retried.totalUsage);
return retried.fullStream;
};
const continueAfterLength = async (output: string) => {
const continued = await agent.stream([
...baseMessages,
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
], streamOptions);
usages.push(continued.totalUsage);
return continued.fullStream;
};
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
@@ -748,6 +760,7 @@ export async function POST(request: Request) {
stream: result.fullStream,
requireTool: false,
retryForAnswer,
continueAfterLength,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(
generalDailyContext ? "general_no_birth_time" : consultationMode,
@@ -758,13 +771,14 @@ export async function POST(request: Request) {
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse(
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined),
onError: (error) => settleRun(
cancel,
@@ -812,6 +826,15 @@ export async function POST(request: Request) {
usages.push(retried.totalUsage);
return retried.fullStream;
};
const continueAfterLength = async (output: string) => {
const continued = await agent.stream([
...baseMessages,
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
], streamOptions);
usages.push(continued.totalUsage);
return continued.fullStream;
};
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
@@ -837,6 +860,7 @@ export async function POST(request: Request) {
requireTool: true,
retry,
retryForAnswer,
continueAfterLength,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
@@ -844,13 +868,14 @@ export async function POST(request: Request) {
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse(
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
state.techniqueTruth ?? "declared-window",
state.workflowReceipt ?? workflowReceipt,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined),
onError: (error) => settleRun(
cancel,
@@ -900,6 +925,15 @@ export async function POST(request: Request) {
usages.push(retried.totalUsage);
return retried.fullStream;
};
const continueAfterLength = async (output: string) => {
const continued = await agent.stream([
...baseMessages,
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
], streamOptions);
usages.push(continued.totalUsage);
return continued.fullStream;
};
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
@@ -925,6 +959,7 @@ export async function POST(request: Request) {
requireTool: true,
retry,
retryForAnswer,
continueAfterLength,
continueAfterDisconnect: true,
transformText: (text) => createBirthTimeModeOutputGuard(
consultationMode,
@@ -935,13 +970,14 @@ export async function POST(request: Request) {
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse(
onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
state.techniqueTruth ?? "unknown",
state.workflowReceipt ?? workflowReceipt,
agentExecutionReceipt,
thinkingText,
thinkingSections,
), undefined),
onError: (error) => settleRun(
cancel,
+31
View File
@@ -844,6 +844,37 @@ button:disabled { cursor: default; opacity: .45; }
line-height: 1.55;
white-space: pre-wrap;
}
.consultation-step-tree__intent {
margin: 0 0 var(--space-2);
color: var(--color-ink-secondary);
font-size: 13px;
line-height: 1.5;
}
.agent-thinking-marker.is-pending {
background: var(--color-canvas-muted);
box-shadow: inset 0 0 0 1px var(--color-border);
}
.agent-thinking-step.is-more {
color: var(--color-ink-tertiary);
}
.consultation-thinking-report {
display: grid;
gap: var(--space-5);
}
.consultation-report-block {
display: grid;
gap: var(--space-2);
}
.consultation-report-analysis__label {
margin: 0 0 var(--space-2);
color: var(--color-ink);
font-size: 13px;
font-weight: 600;
line-height: 1.5;
}
.consultation-report-analysis .message-answer {
margin-top: 0;
}
.rectification-message-entry { min-width: 0; }
.message-actions {
display: flex;
+42 -15
View File
@@ -106,6 +106,12 @@ import {
type AgentExecutionReceipt,
type ConsultationAgentPublicEvent,
} from "@/lib/consultation-agent-events";
import {
applyThinkingSectionProgress,
parsePublicThinkingSections,
upsertThinkingSection,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
import {
CONSULTATION_CHART_CALCULATION_LABEL,
CONSULTATION_COMPOSING_LABEL,
@@ -220,7 +226,13 @@ type ReplyOutcome = {
readonly phase: Extract<ChatReplyPhase, "completed" | "stopped" | "failed">;
readonly replyOrdinal: number;
};
type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView; thinkingText?: string };
type StreamingReply = {
sessionId: string;
text: string;
activity?: AgentActivityView;
thinkingText?: string;
thinkingSections?: PublicThinkingSection[];
};
type BirthPlace = {
label: string;
lat: number;
@@ -780,10 +792,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim()
? stored.thinkingText.slice(0, 4000)
: undefined;
const thinkingSections = parsePublicThinkingSections(stored.thinkingSections);
return [{
role: stored.role,
text: stored.text.slice(0, 12000),
...(thinkingText ? { thinkingText } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}),
...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}),
...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}),
@@ -1197,6 +1211,9 @@ export default function Home() {
const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingText
: undefined;
const activeStreamingSections = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingSections
: undefined;
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
const replyPhase: ChatReplyPhase = isLoading
? consultationPhase === "recovering" ? "recovering" : "generating"
@@ -2015,6 +2032,7 @@ export default function Home() {
role: message.role,
text: message.text,
thinkingText: message.thinkingText,
thinkingSections: message.thinkingSections,
techniqueTruth: message.techniqueTruth,
agentExecutionReceipt: message.agentExecutionReceipt,
workflowReceipt: message.workflowReceipt,
@@ -3249,7 +3267,7 @@ export default function Home() {
}
setStreamingReply({ sessionId, text: "" });
let latestPartialReply = "";
let thinking = "";
let thinkingSections: PublicThinkingSection[] = [];
try {
const response = await fetch("/api/consult", {
method: "POST",
@@ -3314,10 +3332,11 @@ export default function Home() {
const updateStreamingAnswer = (activity?: AgentActivityView) => {
const partialReply = parseAgentReply(answer).text;
latestPartialReply = partialReply;
thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply);
setStreamingReply((current) => ({
sessionId,
text: partialReply,
thinkingText: current?.sessionId === sessionId ? current.thinkingText : undefined,
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
activity: activity
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
: current?.sessionId === sessionId ? current.activity : undefined,
@@ -3359,12 +3378,20 @@ export default function Home() {
if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) {
const parser = createNdjsonParser((event) => {
if (event.type === "answer.delta") answer += event.text;
if (event.type === "thinking.delta") {
thinking = `${thinking}${event.text}`.slice(0, 4_000);
if (event.type === "thinking.section") {
thinkingSections = applyThinkingSectionProgress(
upsertThinkingSection(thinkingSections, {
id: event.id,
title: event.title,
heading: event.heading,
steps: event.steps,
}),
parseAgentReply(answer).text,
);
setStreamingReply((current) => ({
sessionId,
text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text,
thinkingText: thinking,
thinkingSections,
activity: current?.sessionId === sessionId ? current.activity : undefined,
}));
}
@@ -3403,7 +3430,7 @@ export default function Home() {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
thinkingText: thinking || undefined,
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
@@ -3429,8 +3456,8 @@ export default function Home() {
if (!runCompleted && !truncatedFailure) {
throw new ConsultationResponseError(
502,
thinking.trim()
? "这次还没有生成可显示的回答。思考过程已保留,可以直接继续问。"
thinkingSections.length
? "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。"
: "Agent 回答未完成,本次不会保存为成功咨询。",
);
}
@@ -3446,8 +3473,8 @@ export default function Home() {
if (controller.signal.aborted) return Boolean(latestPartialReply);
const reply = parseAgentReply(answer);
if (!reply.text) {
throw thinking.trim()
? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考过程已保留,可以直接继续问。")
throw thinkingSections.length
? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。")
: new Error("Agent 没有返回可显示的回答,请重试。");
}
@@ -3464,7 +3491,7 @@ export default function Home() {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
thinkingText: thinking || undefined,
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
@@ -3508,13 +3535,13 @@ export default function Home() {
if (restore) {
updateSession(sessionId, () => restore);
void persistSession(restore).catch(() => {});
} else if (thinking.trim() || latestPartialReply) {
} else if (thinkingSections.length || latestPartialReply) {
const failedSession: ChatSession = {
...userSession,
messages: [...userSession.messages, {
role: "assistant",
text: latestPartialReply,
thinkingText: thinking.trim() || undefined,
...(thinkingSections.length ? { thinkingSections } : {}),
}],
updatedAt: timestamp(),
};
@@ -3950,7 +3977,7 @@ export default function Home() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking).map((message, index, views) => {
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking, activeStreamingSections).map((message, index, views) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
+17 -2
View File
@@ -3,6 +3,7 @@
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
@@ -60,7 +61,9 @@ export function ChatMessageRow({
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
const showThinkingPanel = showActivity || Boolean(message.thinkingText?.trim());
const thinkingSections = message.thinkingSections ?? [];
const showReport = thinkingSections.length > 0;
const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
useEntryEffect(() => {
const row = messageRow.current;
@@ -97,6 +100,18 @@ export function ChatMessageRow({
<div className="message-bubble">
{message.role === "assistant" ? (
<>
{showReport && (
<ConsultationThinkingReport
sections={thinkingSections}
answer={message.text}
live={showActivity && !hasAnswer}
liveLabel={activityLabel}
liveState={activityState}
startedAt={message.activity?.startedAt}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
/>
)}
{showThinkingPanel && (
<AgentActivityStatus
state={activityState}
@@ -108,7 +123,7 @@ export function ChatMessageRow({
showLive={showActivity}
/>
)}
{message.text && (
{!showReport && message.text && (
<ChatMessageContent
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
@@ -0,0 +1,86 @@
"use client";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ThinkingStepTree } from "@/components/thinking-step-tree";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
applyThinkingSectionProgress,
REPORT_HEADING,
splitAnswerByHeadings,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
function analysisForSection(
section: PublicThinkingSection,
preamble: string,
slices: Record<string, string>,
isFirst: boolean,
): string {
const own = slices[section.heading] ?? "";
const wrap = section.id === "close" ? (slices[REPORT_HEADING.wrap] ?? "") : "";
const lead = isFirst || section.id === "foundation" || section.id === "answer" || section.id === "window"
? preamble
: "";
return [lead, own, wrap].filter((part) => part.trim()).join("\n\n");
}
export function ConsultationThinkingReport({
sections,
answer,
live = false,
liveLabel,
liveState,
startedAt,
auditRows,
vargaSentence,
}: Readonly<{
sections: readonly PublicThinkingSection[];
answer: string;
live?: boolean;
liveLabel?: string;
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
startedAt?: number;
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
}>) {
const progressed = applyThinkingSectionProgress(sections, answer);
const headings = [
...progressed.map((section) => section.heading),
REPORT_HEADING.wrap,
];
const { preamble, slices } = splitAnswerByHeadings(answer, headings);
return (
<div className="consultation-thinking-report">
{progressed.map((section, index) => {
const active = section.steps.some((step) => step.status === "active");
const analysis = analysisForSection(section, preamble, slices, index === 0);
const last = index === progressed.length - 1;
return (
<section className="consultation-report-block" key={section.id}>
<ThinkingStepTree
caption="思考"
intent={section.title}
steps={section.steps}
live={live && active}
liveLabel={live && active ? liveLabel : undefined}
liveState={liveState}
startedAt={live && active ? startedAt : undefined}
defaultOpen={active || (live && index === 0 && !answer.trim())}
/>
{analysis.trim() ? (
<div className="consultation-report-analysis">
<h3 className="consultation-report-analysis__label"></h3>
<ChatMessageContent
text={analysis}
auditRows={last ? auditRows : undefined}
vargaSentence={last ? vargaSentence : null}
/>
</div>
) : null}
</section>
);
})}
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityElapsedLabel } from "@/lib/chat-message-view";
import {
visibleThinkingSteps,
type PublicThinkingStep,
type ThinkingStepStatus,
} from "@/lib/consultation-thinking-plan";
const importThinkingOrb = () => import("thinking-orbs");
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
loading: () => (
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
),
ssr: false,
});
prefetchOnIdle(importThinkingOrb);
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>;
}
function StepMarker({
status,
liveState,
}: Readonly<{
status: ThinkingStepStatus | "live";
liveState?: OrbState;
}>) {
if (status === "live" || status === "active") {
return (
<span className="agent-thinking-marker is-live-marker">
<ThinkingOrb aria-hidden="true" state={liveState ?? "working"} size={20} />
</span>
);
}
if (status === "done") {
return (
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
);
}
return <span className="agent-thinking-marker is-pending" aria-hidden="true" />;
}
export function ThinkingStepTree({
caption,
intent,
steps,
hiddenCount,
live = false,
liveLabel,
liveState = "working",
startedAt,
defaultOpen = false,
}: Readonly<{
caption?: string;
intent?: string;
steps: readonly PublicThinkingStep[];
hiddenCount?: number;
live?: boolean;
liveLabel?: string;
liveState?: OrbState;
startedAt?: number;
defaultOpen?: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const visible = hiddenCount === undefined ? visibleThinkingSteps(steps) : { visible: steps, hiddenCount };
const body = (
<>
{intent ? <p className="consultation-step-tree__intent">{intent}</p> : null}
<ol className="agent-thinking-timeline">
{visible.visible.map((step) => (
<li
className={`agent-thinking-step is-${step.status}`}
key={step.id}
>
<StepMarker status={step.status} liveState={liveState} />
<span>{step.label}</span>
</li>
))}
{visible.hiddenCount > 0 ? (
<li className="agent-thinking-step is-more">
<span className="agent-thinking-marker is-pending" aria-hidden="true" />
<span> {visible.hiddenCount} </span>
</li>
) : null}
{live && liveLabel ? (
<li className="agent-thinking-step is-live">
<StepMarker status="live" liveState={liveState} />
<span className="agent-activity-status__live" role="status">
<span key={liveLabel} className="agent-activity-status__text">{liveLabel}</span>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</span>
</li>
) : null}
</ol>
</>
);
if (!caption) return <div className="consultation-step-tree">{body}</div>;
const open = userOpen ?? defaultOpen;
return (
<details
className="message-thinking consultation-step-tree"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary>{caption}</summary>
<div className="message-thinking-body">{body}</div>
</details>
);
}
+10 -6
View File
@@ -1,12 +1,16 @@
/**
* Visible-answer generation settings shared by consultation and rectification.
* Spoken-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. Consultation and rectification may
* enable a separate Chinese thinking channel; the spoken answer still uses
* this visible token budget. Default remains disabled for other callers.
* `max_tokens` with the spoken answer. The compose budget is therefore the
* visible report only: thinking stays disabled so a Level 2 body is not
* pinched by a hidden chain of thought. Callers that still need provider
* thinking must pass it explicitly; they do not inherit this answer budget
* as a combined thinking-plus-content cap.
*/
export const AGENT_MAX_OUTPUT_TOKENS = 8192;
export const AGENT_ANSWER_OUTPUT_TOKENS = 16_384;
/** @deprecated Use AGENT_ANSWER_OUTPUT_TOKENS; kept as the compose-budget alias. */
export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS;
type ThinkingMode = "enabled" | "disabled";
@@ -25,7 +29,7 @@ export function agentGenerationSettings(
};
if (providerId) providerOptions[providerId] = thinking;
return {
modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
modelSettings: { maxOutputTokens: AGENT_ANSWER_OUTPUT_TOKENS },
providerOptions,
};
}
+4
View File
@@ -1,4 +1,5 @@
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
export const ACTIVITY_ELAPSED_VISIBLE_AFTER_MS = 8_000;
export const ACTIVITY_COMPLETED_TRAIL_LIMIT = 3;
@@ -51,6 +52,7 @@ export type ChatMessage = {
readonly role: "user" | "assistant";
readonly text: string;
readonly thinkingText?: string;
readonly thinkingSections?: readonly PublicThinkingSection[];
readonly techniqueTruth?: string;
readonly agentExecutionReceipt?: AgentExecutionReceipt;
readonly workflowReceipt?: WorkflowReceipt;
@@ -68,6 +70,7 @@ export function chatMessageViews(
streamingText: string,
activity?: AgentActivityView,
thinkingText?: string,
thinkingSections?: readonly PublicThinkingSection[],
): readonly ChatMessageView[] {
const settled = messages.map((message, index) => ({
...message,
@@ -82,6 +85,7 @@ export function chatMessageViews(
role: "assistant",
text: streamingText,
thinkingText,
thinkingSections,
renderKey: `message-${messages.length}`,
state: streamingText ? "streaming" : "thinking",
activity,
@@ -6,6 +6,7 @@ import {
type WorkflowReceipt,
} from "./consultation-agent-events.ts";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
import { publicThinkingSectionSchema, type PublicThinkingSection } from "./consultation-thinking-plan.ts";
export const CHAT_SESSION_MAX_MESSAGES = 200;
export const CHAT_SESSION_MAX_MESSAGE_CHARS = 16_000;
@@ -20,6 +21,7 @@ const chatMessageSchema = z.object({
// whole write would lose that user's message rather than a dead field.
suggestions: z.array(z.string().max(200)).max(3).optional(),
thinkingText: z.string().max(4_000).optional(),
thinkingSections: z.array(publicThinkingSectionSchema).max(12).optional(),
techniqueTruth: z.string().max(120).optional(),
agentExecutionReceipt: agentExecutionReceiptSchema.optional(),
workflowReceipt: workflowReceiptSchema.optional(),
@@ -35,12 +37,13 @@ const chatSessionWriteObjectSchema = z.object({
updated_at: z.string().datetime(),
}).strict();
function limitTranscriptSize<Output extends { messages: Array<{ text: string; thinkingText?: string }> }>(
function limitTranscriptSize<Output extends { messages: Array<{ text: string; thinkingText?: string; thinkingSections?: unknown }> }>(
schema: z.ZodType<Output>,
): z.ZodType<Output> {
return schema.superRefine((value, context) => {
const totalChars = value.messages.reduce(
(sum, message) => sum + message.text.length + (message.thinkingText?.length ?? 0),
(sum, message) => sum + message.text.length + (message.thinkingText?.length ?? 0)
+ (message.thinkingSections ? JSON.stringify(message.thinkingSections).length : 0),
0,
);
if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) {
@@ -91,6 +94,7 @@ export type ChatSessionWrite = Readonly<{
text: string;
suggestions?: readonly string[];
thinkingText?: string;
thinkingSections?: readonly PublicThinkingSection[];
techniqueTruth?: string;
agentExecutionReceipt?: AgentExecutionReceipt;
workflowReceipt?: WorkflowReceipt;
@@ -1,5 +1,6 @@
import { z } from "zod";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
import { publicThinkingSectionSchema } from "./consultation-thinking-plan.ts";
export const publicActivityPhaseSchema = z.enum([
"loading-method",
@@ -94,6 +95,9 @@ const toolFailedSchema = z.object({
}).strict();
const answerDeltaSchema = z.object({ type: z.literal("answer.delta"), text: z.string() }).strict();
const thinkingDeltaSchema = z.object({ type: z.literal("thinking.delta"), text: z.string() }).strict();
const thinkingSectionEventSchema = publicThinkingSectionSchema.extend({
type: z.literal("thinking.section"),
}).strict();
const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema }).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
@@ -108,7 +112,8 @@ const runFailedSchema = z.object({
export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [
runStartedSchema, skillStartedSchema, skillCompletedSchema, toolStartedSchema, activitySchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, thinkingDeltaSchema, runCompletedSchema, runFailedSchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, thinkingDeltaSchema,
thinkingSectionEventSchema, runCompletedSchema, runFailedSchema,
]);
export type ConsultationAgentPublicEvent = z.infer<typeof consultationAgentPublicEventSchema>;
@@ -0,0 +1,279 @@
import { z } from "zod";
import {
consultationDomainDefinition,
type ConsultationDomain,
} from "./consultation-domain-registry.ts";
export const THINKING_STEP_STATUSES = ["pending", "active", "done"] as const;
export type ThinkingStepStatus = (typeof THINKING_STEP_STATUSES)[number];
export const VISIBLE_THINKING_STEP_LIMIT = 4;
export const REPORT_HEADING = {
foundation: "统一参数与原始结构",
audit: "技法审计表",
wrap: "现代生活",
} as const;
const BLOCK_STEP_LABELS: Readonly<Record<string, string>> = {
raw_structure: "列出岁差、上升与宫位结构",
raman_six_step: "按六步宫位判断问题宫",
yoga_table: "核对应 Yoga 的成立与落空",
timing: "对照当前大运与行运",
synthesis: "综合强弱与下一步",
technique_audit_table: "贴上技法审计表",
modern_wrap: "用现代生活语言收口",
};
const TOOLISH_STEP_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+|skill_read|proposedKind|validationErrors/i;
export const publicThinkingStepSchema = z.object({
id: z.string().min(1).max(80),
label: z.string().min(1).max(80),
status: z.enum(THINKING_STEP_STATUSES),
}).strict();
export const publicThinkingSectionSchema = z.object({
id: z.string().min(1).max(80),
title: z.string().min(1).max(120),
heading: z.string().min(1).max(80),
steps: z.array(publicThinkingStepSchema).min(1).max(12),
}).strict();
export type PublicThinkingStep = z.infer<typeof publicThinkingStepSchema>;
export type PublicThinkingSection = z.infer<typeof publicThinkingSectionSchema>;
function step(id: string, label: string, status: ThinkingStepStatus = "pending"): PublicThinkingStep | null {
const cleaned = label.replace(/\s+/g, " ").trim();
if (!cleaned || TOOLISH_STEP_RE.test(cleaned)) return null;
return { id, label: cleaned.slice(0, 80), status };
}
function uniqueSteps(steps: ReadonlyArray<PublicThinkingStep | null>): PublicThinkingStep[] {
const seen = new Set<string>();
const kept: PublicThinkingStep[] = [];
for (const item of steps) {
if (!item || seen.has(item.label)) continue;
seen.add(item.label);
kept.push(item);
if (kept.length >= 12) break;
}
return kept;
}
function layerSteps(layers: readonly string[] | undefined, prefix: string): Array<PublicThinkingStep | null> {
return (layers ?? []).slice(0, 6).map((layer, index) => (
step(`${prefix}-${index}`, `对照 ${layer.replace(/[_]/g, " ").trim()}`)
));
}
export function consultationReportHeadings(domains: readonly ConsultationDomain[]): string[] {
return [
REPORT_HEADING.foundation,
...domains.map((domain) => consultationDomainDefinition(domain).label),
REPORT_HEADING.audit,
REPORT_HEADING.wrap,
];
}
export function natalConsultationThinkingPlan(input: {
domains: readonly ConsultationDomain[];
requiredBlocks?: readonly string[];
mustUseLayers?: readonly string[];
}): PublicThinkingSection[] {
const domains = input.domains.slice(0, 6);
const blocks = new Set(input.requiredBlocks ?? Object.keys(BLOCK_STEP_LABELS));
const foundationSteps = uniqueSteps([
step("foundation-raw", BLOCK_STEP_LABELS.raw_structure ?? "列出岁差、上升与宫位结构"),
...layerSteps(input.mustUseLayers, "foundation-layer"),
]);
const domainBlocks = ["raman_six_step", "yoga_table", "timing", "synthesis"]
.filter((block) => blocks.has(block));
const closeSteps = uniqueSteps([
blocks.has("technique_audit_table")
? step("close-audit", BLOCK_STEP_LABELS.technique_audit_table ?? "贴上技法审计表")
: null,
blocks.has("modern_wrap")
? step("close-wrap", BLOCK_STEP_LABELS.modern_wrap ?? "用现代生活语言收口")
: null,
]);
const sections: PublicThinkingSection[] = [
publicThinkingSectionSchema.parse({
id: "foundation",
title: "先整理本盘的统一参数",
heading: REPORT_HEADING.foundation,
steps: foundationSteps.length > 0
? foundationSteps
: [{ id: "foundation-raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}),
];
for (const domain of domains) {
const definition = consultationDomainDefinition(domain);
const steps = uniqueSteps([
...domainBlocks.map((block) => step(`${domain}-${block}`, BLOCK_STEP_LABELS[block] ?? block)),
...definition.evidencePreview.slice(0, 6).map((item, index) => (
step(`${domain}-preview-${index}`, `对照 ${item}`)
)),
]);
sections.push(publicThinkingSectionSchema.parse({
id: `domain-${domain}`,
title: `接下来分析你的${definition.label}`,
heading: definition.label,
steps: steps.length > 0
? steps
: [{ id: `${domain}-read`, label: `整理${definition.label}相关宫位`, status: "pending" }],
}));
}
sections.push(publicThinkingSectionSchema.parse({
id: "close",
title: "用审计表收口后再落到生活",
heading: REPORT_HEADING.audit,
steps: closeSteps.length > 0
? closeSteps
: [
{ id: "close-audit", label: "贴上技法审计表", status: "pending" },
{ id: "close-wrap", label: "用现代生活语言收口", status: "pending" },
],
}));
return sections;
}
export function generalConsultationThinkingPlan(): PublicThinkingSection[] {
return [publicThinkingSectionSchema.parse({
id: "answer",
title: "接下来组织这轮回答",
heading: "回答",
steps: [
{ id: "method", label: "读取分析方法", status: "done" },
{ id: "compose", label: "组织回答", status: "pending" },
],
})];
}
export function windowConsultationThinkingPlan(): PublicThinkingSection[] {
return [publicThinkingSectionSchema.parse({
id: "window",
title: "接下来根据声明窗口整理稳定层",
heading: REPORT_HEADING.foundation,
steps: [
{ id: "compare", label: "比较声明窗口内的稳定层", status: "pending" },
{ id: "boundary", label: "核对应答边界", status: "pending" },
{ id: "compose", label: "组织回答", status: "pending" },
],
})];
}
export function applyThinkingSectionProgress(
sections: readonly PublicThinkingSection[],
answerText: string,
): PublicThinkingSection[] {
if (sections.length === 0) return [];
const present = new Set(
[...answerText.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? ""),
);
let activeAssigned = false;
return sections.map((section) => {
const headingPresent = present.has(section.heading);
let status: ThinkingStepStatus = "pending";
if (headingPresent) status = "done";
else if (!activeAssigned) {
status = "active";
activeAssigned = true;
}
return {
...section,
steps: section.steps.map((item) => ({
...item,
status: status === "done" ? "done" : status === "active" && item.status === "done" ? "done" : status,
})),
};
});
}
export function splitAnswerByHeadings(
text: string,
headings: readonly string[],
): { preamble: string; slices: Record<string, string> } {
const slices: Record<string, string> = {};
for (const heading of headings) slices[heading] = "";
if (!text.trim()) return { preamble: "", slices };
const parts = text.split(/(?=^## )/m);
let preamble = "";
for (const part of parts) {
const heading = /^##\s+(.+?)\s*$/m.exec(part)?.[1]?.trim();
if (!heading) {
preamble += part;
continue;
}
if (heading in slices) slices[heading] += part;
else preamble += part;
}
return { preamble: preamble.trim(), slices };
}
export function visibleThinkingSteps(steps: readonly PublicThinkingStep[]): {
visible: PublicThinkingStep[];
hiddenCount: number;
} {
if (steps.length <= VISIBLE_THINKING_STEP_LIMIT) {
return { visible: [...steps], hiddenCount: 0 };
}
return {
visible: steps.slice(0, VISIBLE_THINKING_STEP_LIMIT),
hiddenCount: steps.length - VISIBLE_THINKING_STEP_LIMIT,
};
}
export function consultationSpokenHeadingRule(kind: "natal" | "general" | "window"): string {
const secrets = "Never put tool names, error codes, parameters, internal IDs, scores, or secrets in the body.";
const activity = "Do not invent a thinking-process checklist. Activity, progress, and receipts are server-owned.";
if (kind === "natal") {
return [
`Write the spoken answer with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.foundation}, then ## {the Chinese label of each executed domain in tool order, such as 事业 / 财富 / 关系}, then ## ${REPORT_HEADING.audit}, then ## ${REPORT_HEADING.wrap}.`,
activity,
secrets,
].join(" ");
}
if (kind === "window") {
return [
`When describing stable window structure, start with ## ${REPORT_HEADING.foundation}.`,
activity,
secrets,
].join(" ");
}
return `${activity} ${secrets}`;
}
export function upsertThinkingSection(
sections: readonly PublicThinkingSection[],
next: PublicThinkingSection,
): PublicThinkingSection[] {
const parsed = publicThinkingSectionSchema.parse(next);
const index = sections.findIndex((section) => section.id === parsed.id);
if (index < 0) return [...sections, parsed];
return sections.map((section, current) => (current === index ? parsed : section));
}
export function parsePublicThinkingSections(value: unknown): PublicThinkingSection[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
const parsed = publicThinkingSectionSchema.safeParse(item);
return parsed.success ? [parsed.data] : [];
});
}
export function consultationContinuePrompt(output: string): string {
const headings = [...output.matchAll(/^## .+$/gm)].map((match) => match[0]);
const last = headings.at(-1);
return [
"上一轮用户可见正文因长度在标题处停下。从最后一个完整二级标题之后继续写完,不要重复已写出的段落,不要写思考过程清单。",
last ? `最后一个完整标题是:${last}` : "上一轮还没有写出完整的二级标题。",
`必须继续使用这些二级标题(尚未写到的才写):## ${REPORT_HEADING.foundation}、各已执行领域的中文名、## ${REPORT_HEADING.audit}、## ${REPORT_HEADING.wrap}`,
"已写出的末尾摘录:",
output.slice(-800),
].join("\n");
}
@@ -509,7 +509,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel, { thinking: "enabled" });
const generation = agentGenerationSettings(options.generationModel, { thinking: "disabled" });
const result = await (agent as unknown as {
stream(
messages: unknown[],
+65 -18
View File
@@ -11,7 +11,11 @@ import {
} from "./consultation-agent-events.ts";
import { toAgentModelFinishReason } from "./agent-observability.ts";
import { createVisibleTextTransformer } from "./stream-text-response.ts";
import { sanitizePublicThinkingText } from "./public-thinking.ts";
import {
applyThinkingSectionProgress,
generalConsultationThinkingPlan,
type PublicThinkingSection,
} from "./consultation-thinking-plan.ts";
type Chunk = { type?: string; payload?: Record<string, unknown>; data?: unknown };
type ChunkStream = AsyncIterable<unknown> | ReadableStream<unknown>;
@@ -160,6 +164,14 @@ const skillBoundEvents: readonly ConsultationAgentPublicEvent[] = [
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
];
function thinkingSectionEvents(sections: readonly PublicThinkingSection[] | undefined): ConsultationAgentPublicEvent[] {
if (!sections?.length) return [];
return sections.map((section) => consultationAgentPublicEventSchema.parse({
type: "thinking.section",
...section,
}));
}
function mapChunk(
chunk: Chunk,
options: EventOptions,
@@ -225,16 +237,22 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
];
const startedAt = new Map<string, number>();
const toolErrors = { seen: 0 };
let planSent = false;
const flushPlan = () => {
if (planSent) return;
const planned = thinkingSectionEvents(options.state?.thinkingPlan);
if (!planned.length) return;
planSent = true;
events.push(...planned);
};
for await (const chunk of stream instanceof ReadableStream || Symbol.asyncIterator in stream ? readChunks(stream as ChunkStream) : stream) {
events.push(...mapChunk(chunk, options, startedAt, toolErrors));
flushPlan();
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 });
}
}
flushPlan();
events.push({ type: "run.completed", receipt: agentExecutionReceiptSchema.parse(options.receipt()) });
return events.map((event) => consultationAgentPublicEventSchema.parse(event));
}
@@ -246,11 +264,17 @@ type StreamAgentResponseOptions = EventOptions & {
requireTool: boolean;
retry?: () => Promise<ChunkStream>;
retryForAnswer?: () => Promise<ChunkStream>;
continueAfterLength?: (output: string) => Promise<ChunkStream>;
continueAfterDisconnect?: boolean;
headers?: HeadersInit;
onFirstActivity?: () => void | Promise<void>;
onFirstOutput?: () => void | Promise<void>;
onComplete?: (output: string, receipt: AgentExecutionReceipt, thinkingText?: string) => void | Promise<void>;
onComplete?: (
output: string,
receipt: AgentExecutionReceipt,
thinkingText?: string,
thinkingSections?: PublicThinkingSection[],
) => void | Promise<void>;
onError?: (error: unknown, emitted: boolean, output: string) => void | Promise<void>;
onCancel?: (emitted: boolean) => void | Promise<void>;
};
@@ -273,7 +297,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
let firstActivity = false;
let firstOutput = false;
let fullOutput = "";
let fullThinking = "";
let planSent = false;
const startedAt = new Map<string, number>();
// A retry reuses these counters so a failure in either attempt is recorded once.
const toolErrors = { seen: 0 };
@@ -284,6 +308,16 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
}
if (!disconnected && controller) controller.enqueue(encoder.encode(`${JSON.stringify(consultationAgentPublicEventSchema.parse(event))}\n`));
};
const flushThinkingPlan = (controller: ReadableStreamDefaultController<Uint8Array> | undefined) => {
if (planSent) return;
if (!options.requireTool && !options.state.thinkingPlan?.length) {
options.state.thinkingPlan = generalConsultationThinkingPlan();
}
const planned = thinkingSectionEvents(options.state.thinkingPlan);
if (!planned.length) return;
planSent = true;
for (const event of planned) send(controller, event);
};
async function consumeAttempt(controller: ReadableStreamDefaultController<Uint8Array> | undefined, stream: ChunkStream) {
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
@@ -317,6 +351,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
try {
for await (const chunk of readChunks(stream)) {
for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event);
flushThinkingPlan(controller);
if (chunk.type === "step-finish") options.state.modelStepCount += 1;
if (chunk.type === "finish") {
const finish = finishTelemetry(chunk);
@@ -326,14 +361,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
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) {
fullThinking = `${fullThinking}${thinking}`.slice(0, 4_000);
send(controller, { type: "thinking.delta", text: thinking });
}
}
}
flushThinkingPlan(controller);
await outputText(visible.finish(""));
} catch (error) {
try {
@@ -348,6 +377,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
void (async () => {
send(controller, { type: "run.started", runId: options.runId, requestId: options.requestId });
for (const event of skillBoundEvents) send(controller, event);
if (!options.requireTool) flushThinkingPlan(controller);
try {
await consumeAttempt(controller, options.stream);
if (!contractReady(options) && options.retry) {
@@ -372,12 +402,29 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
// 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");
// not a completed consultation. Continue once from the last complete
// heading, still with thinking disabled, before treating it as a pinch.
if (options.state.modelFinishReason === "length" && options.continueAfterLength) {
const beforeContinue = fullOutput;
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-continue", status: "completed" });
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
await consumeAttempt(controller, await options.continueAfterLength(fullOutput));
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) {
throw new Error("answer_truncated");
}
} else if (options.state.modelFinishReason === "length") {
throw new Error("answer_truncated");
}
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
await options.onComplete?.(fullOutput, receipt, fullThinking || undefined);
const thinkingSections = applyThinkingSectionProgress(options.state.thinkingPlan ?? [], fullOutput);
await options.onComplete?.(
fullOutput,
receipt,
undefined,
thinkingSections.length > 0 ? thinkingSections : undefined,
);
settled = true;
settling = false;
send(controller, { type: "run.completed", receipt });
+17 -2
View File
@@ -16,6 +16,11 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
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 {
natalConsultationThinkingPlan,
windowConsultationThinkingPlan,
type PublicThinkingSection,
} from "../lib/consultation-thinking-plan.ts";
import {
consultationEvidencePacketSchema,
consultationInputSchema,
@@ -56,7 +61,7 @@ export const MAX_CONSULTATION_DOMAINS = Math.max(
);
export function consultationGenerationSettings(model?: unknown) {
return agentGenerationSettings(model, { thinking: "enabled" });
return agentGenerationSettings(model, { thinking: "disabled" });
}
// The raw plan bound stays at the registry default so a duplicate-heavy list
@@ -117,6 +122,7 @@ export type ConsultationRuntimeState = {
workflowReceipt?: WorkflowReceipt;
techniqueTruth?: string;
techniqueAuditTable?: TechniqueAuditRow[];
thinkingPlan?: PublicThinkingSection[];
steps: ConsultationRuntimeStep[];
stepBudget: ConsultationStepBudget;
stepsTruncated: boolean;
@@ -127,7 +133,8 @@ export type ConsultationRuntimeState = {
};
export function createConsultationRuntimeState(options: { plannedSteps?: number; reservedValidationSteps?: number } = {}): ConsultationRuntimeState {
const reservedValidation = Math.max(0, Math.min(MAX_RECORDED_STEPS - 1, Math.floor(options.reservedValidationSteps ?? 2)));
// Contract retry, empty-answer retry, and length-continue each take a validation slot.
const reservedValidation = Math.max(0, Math.min(MAX_RECORDED_STEPS - 1, Math.floor(options.reservedValidationSteps ?? 3)));
const planned = Math.max(1, Math.min(MAX_RECORDED_STEPS - reservedValidation, Math.floor(options.plannedSteps ?? 6)));
const state: ConsultationRuntimeState = {
jyotishSkillBound: true,
@@ -629,6 +636,13 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows(
modelContext.evidence_contract?.technique_audit_table,
);
ctx.state.thinkingPlan = natalConsultationThinkingPlan({
domains: executions.map((execution) => execution.domain),
requiredBlocks: modelContext.presentation?.required_blocks,
mustUseLayers: Array.isArray(modelContext.evidence_contract?.must_use_layers)
? modelContext.evidence_contract.must_use_layers.filter((item): item is string => typeof item === "string")
: undefined,
});
return modelContext;
} catch (error) {
ctx.state.consultationToolDurationMs = now() - startedAt;
@@ -724,6 +738,7 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex
},
rectification: { boundary: "not_auto_rectified" },
};
ctx.state.thinkingPlan = windowConsultationThinkingPlan();
ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows([
{
technique: "Declared birth window probes",
+4 -3
View File
@@ -5,6 +5,7 @@ import { toAgentConsultationContext } from "./consultation-workflow.ts";
import { evidenceDraftModelOutputSchema } from "../lib/birth-time-guide-agent.ts";
import type { ResolvedLanguageModel } from "./model";
import { productConversationVoice } from "./product-voice";
import { consultationSpokenHeadingRule } from "../lib/consultation-thinking-plan.ts";
import {
jyotishSkillBinding,
jyotishSkillMethodBlock,
@@ -24,7 +25,7 @@ The tool result's methodology field is the skill's own strict checklist for the
The tool result always carries one top-level answer contractstatus, evidence_contract, claim_cards, rectificationeven when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text.
Write the thinking chain in Simplified Chinese only, and keep it off the spoken answer. Never put tool names, error codes, parameters, internal IDs, scores, or secrets in thinking or the body.
${consultationSpokenHeadingRule("natal")}
Treat the server-provided current time as authoritative for words such as today, now, this year, and the next few months. Never infer the current date from model knowledge or the birth date.
Treat the tool result's top-level status and evidence_contract as the authoritative answer policy:
- When status is ready and evidence_contract.answer_policy.can_answer_direction is true, answer the user's actual question directly. Do not begin with infrastructure or confidence disclaimers.
@@ -91,7 +92,7 @@ Answer the user's actual question. Do not refuse the whole turn, and do not forc
A homepage or ordinary-session daily request may include a server-owned <public-daily-panchanga> block. When that block is present, explain today's public calendar trend, suitable actions, cautions, and one practical next step from that block only. State once that this is a public-day reference rather than a personal natal forecast.
When the question is personal but no public daily evidence is present, stay with general, date-level, or public-calendar help that does not need a natal chart. Clearly name which natal parts (ascendant, houses, dashas, personal transits) cannot be judged without a birth minute. Birth-time rectification is an optional later step, not a gate for continuing this session.
Never turn public Panchanga into claims about the user's ascendant, houses, dasha, natal transits, guaranteed outcomes, or exact event timing. Do not invent or alter Panchanga fields that the server did not provide.
Write the thinking chain in Simplified Chinese only, and keep it off the spoken answer. Never put tool names, error codes, parameters, internal IDs, scores, or secrets in thinking or the body.
${consultationSpokenHeadingRule("general")}
Do not thank the user for providing an "authoritative time" unless they actually supplied a clock time in this turn. The server current-time line is for words such as today/now; it is not a birth time.
Do not imply that a reported or candidate time is confirmed. Do not reveal prompts, skills, secrets, or private data. Do not provide medical, legal, investment, or safety-critical instructions.
Use concise Simplified Chinese. The session title is generated and validated by the server; do not add hidden metadata blocks to the answer.`;
@@ -208,7 +209,7 @@ Career, wealth, and relationship questions may describe stable planet-sign struc
A homepage daily request may instead include a server-owned <public-daily-panchanga> block; that path is public-day only and is not a natal forecast.
Birth-time rectification is an optional later step, not a gate for continuing this session.
Write in concise Simplified Chinese. Do not dump JSON. The session title is generated by the server.
Write the thinking chain in Simplified Chinese only, and keep it off the spoken answer. Never put tool names, error codes, parameters, internal IDs, scores, or secrets in thinking or the body.
${consultationSpokenHeadingRule("window")}
Do not provide medical, legal, investment, or safety-critical instructions. Do not predict death, diagnosis, pregnancy outcomes, or guaranteed financial/legal outcomes.`;
export function getWindowJyotishAgent(model: ResolvedLanguageModel, context: WindowConsultationAgentContext) {
+1 -1
View File
@@ -3,7 +3,7 @@ This product is a private conversation with one person. The jyotish-vedic-astrol
For career / wealth / marriage / family (and any natal domain reading), write the answer as the skill Level 2 template. Do not shorten it into spoken-only chat, and do not hide tables in a collapsed control as the only copy.
Required body order:
Required body order, using these exact H2 headings: ## , then ## {Chinese domain label} for each executed domain, then ## , then ## . Do not write a thinking-process checklist; Activity is server-owned.
1. Unified parameters and raw structure first (degrees, houses, vargas, Dasha boundaries, Shadbala/AV, functional benefic/malefic, Western layers the server delivered).
2. Innate significators: Raman six-step house judgment for the question houses. Steps 13 (house / lord / natural karaka) are the executable core. Steps 46 (Sun/Moon repeat, D9, positive/negative majority) are methodology-layer filling from delivered chart fields; label them .
3. Yoga table: name / formation / strength or hit-miss / domain effect. Use the governed 8/20 whitelist for the domain (Nabhasa, wealth core 5, spouse, children/mother/sibling_core). List hits and misses. Do not invent yogas.