fix(consult): BUG-954~956/958 窗口方法块、abort 分码、合同降级

窗口 Agent 注入不含本命骨架的方法块;tripwire abort 走 skill_binding_failed;
无工具但有正文降级交付;应期问题仍先调工具。BUG-957 等窗口线验证后再做。
This commit is contained in:
jesse-ux
2026-09-18 16:37:43 +08:00
parent e5b2ad14dd
commit 5b6abc238b
13 changed files with 594 additions and 27 deletions
+1
View File
@@ -179,6 +179,7 @@ export const logAgentObservability = createAgentObservabilityLogger();
const knownErrorCodes = new Set([
"runtime_contract_incomplete",
"skill_binding_failed",
"empty_answer",
"answer_truncated",
"calculation_failed",
+79 -3
View File
@@ -100,6 +100,13 @@ function isTimeoutOrAbort(error: unknown) {
type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed";
type ProviderStreamErrorCode = "thinking_tool_choice_unsupported" | "provider_error";
const SKILL_BINDING_FAILED = "skill_binding_failed";
const SKILL_BINDING_ABORT_STEP = "skill-binding-abort";
const RUNTIME_CONTRACT_INCOMPLETE_STEP = "runtime-contract-incomplete";
const CONTRACT_DEGRADED_STEP = "contract-degraded";
/** Server-owned; never generated by the model. Voice: 直接,不说法务腔. */
export const CONTRACT_DEGRADED_NOTE = "\n\n这次没跑完星盘计算,上面是模型直接写的,先看着。";
function providerErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
@@ -118,6 +125,7 @@ function chunkProviderError(chunk: Chunk): unknown {
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 === SKILL_BINDING_FAILED) return "runtime_contract_incomplete";
if (error instanceof Error && error.message === "empty_answer") {
return emitted ? "answer_truncated" : "empty_answer";
}
@@ -338,6 +346,33 @@ function contractReady(options: StreamAgentResponseOptions) {
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
}
function isSkillBindingAbortError(error: unknown) {
if (!(error instanceof Error)) return false;
if (error.message === SKILL_BINDING_FAILED) return true;
return error.message.includes("not bound into the system prompt");
}
function isSkillBindingTripwire(chunk: Chunk) {
if (chunk.type !== "tripwire") return false;
const reason = typeof chunk.payload?.reason === "string" ? chunk.payload.reason : "";
const processorId = typeof chunk.payload?.processorId === "string" ? chunk.payload.processorId : "";
return processorId === "jyotish-skill-bound" || reason.includes("not bound into the system prompt");
}
function recordSkillBindingAbort(options: StreamAgentResponseOptions) {
if (options.state.steps.some((step) => step.name === SKILL_BINDING_ABORT_STEP)) return;
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: SKILL_BINDING_ABORT_STEP,
status: "failed",
failureCode: SKILL_BINDING_FAILED,
});
console.error("[consult-binding-error]", {
requestId: options.requestId,
code: SKILL_BINDING_FAILED,
});
}
function sliceAddedVisibleText(before: string, after: string) {
return after.length > before.length && /\S/.test(after.slice(before.length));
}
@@ -351,6 +386,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
let firstActivity = false;
let firstOutput = false;
let fullOutput = "";
let uncontractedText = "";
let thinkingText = "";
let planSent = false;
// Pass 4 buffers only the current open sentence. Closed sentences are
@@ -439,8 +475,13 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
// 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) || drainingSpoken()) return;
// explained itself instead of answering. Drop it from the live stream, but
// keep a copy so a still-red contract can degrade instead of discarding it.
if (!contractReady(options) || drainingSpoken()) {
if (!contractReady(options) && text) uncontractedText += text;
return;
}
uncontractedText = "";
held += text;
if (!held) return;
if (!composingSent) {
@@ -466,6 +507,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
const stepCountBeforeAttempt = options.state.modelStepCount;
try {
for await (const chunk of readChunks(stream)) {
if (isSkillBindingTripwire(chunk)) {
recordSkillBindingAbort(options);
throw new Error(SKILL_BINDING_FAILED);
}
if (chunk.type === "error") {
const raw = chunkProviderError(chunk);
const code = classifyProviderStreamError(raw);
@@ -499,6 +544,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
flushThinkingPlan(controller);
await outputText(visible.finish(""));
} catch (error) {
if (isSkillBindingAbortError(error)) {
recordSkillBindingAbort(options);
throw new Error(SKILL_BINDING_FAILED);
}
if (isTimeoutOrAbort(error)) {
appendConsultationRuntimeStep(options.state, {
kind: "abort",
@@ -661,7 +710,34 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
drainSpoken: Boolean(options.composeAnswer),
});
}
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
if (!contractReady(options)) {
const canDegrade = options.requireTool
&& options.state.consultationToolSuccessCount === 0
&& /\S/.test(uncontractedText);
if (canDegrade) {
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: CONTRACT_DEGRADED_STEP,
status: "failed",
});
if (!firstOutput) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: uncontractedText });
fullOutput += uncontractedText;
send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE });
fullOutput += CONTRACT_DEGRADED_NOTE;
emitted = true;
} else {
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: RUNTIME_CONTRACT_INCOMPLETE_STEP,
status: "failed",
});
throw new Error("runtime_contract_incomplete");
}
}
const findings = await publishFindings(controller);
const composed = await composeOnce(controller, findings);
if (!composed) {
+4
View File
@@ -9,6 +9,7 @@ import { consultationSpokenHeadingRule } from "../lib/consultation-thinking-plan
import {
jyotishSkillBinding,
jyotishSkillMethodBlock,
jyotishSkillMethodCoreBlock,
} from "./skill-binding.ts";
export { consultationInputSchema, consultationWorkflowReceipt, consultationWorkflowResponseSchema, runConsultationWorkflow, toAgentConsultationContext, toModelOutput } from "./consultation-workflow.ts";
@@ -176,7 +177,10 @@ export function getBirthTimeGuideAgent(model: ResolvedLanguageModel) {
const windowJyotishInstructions = `You are the guide for a conversational Vedic astrology product.
${productConversationVoice}
This request has a declared birth window, not a single birth minute. Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time. Probe clocks in the tool result are comparison samples only.
${jyotishSkillMethodCoreBlock}
The bound skill method is this product's answering contract. Window answers do not use the natal Level 2 report skeleton; the window output contract below takes priority over any report-template or precise-timing language in the bound method.
Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.
Timing questions still require calling the tool first. Answer from stable_layers as directional structure, and name which parts need a birth minute. Do not skip the calculation or refuse the whole question because precise timing is unavailable.
Treat the tool result's answer_policy as a hard output contract:
- can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome.
- Answer only from stable_layers as personal structure that holds across the declared window.
+13 -3
View File
@@ -109,15 +109,25 @@ function boundMethod(): string {
return excerpt;
}
const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
export const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
export const jyotishSkillMethodBlock = `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
<jyotish-skill name="${skill.name}">
const NATAL_REPORT_SKELETON = "For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). ";
function methodBlock(reportSkeleton: string) {
return `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. ${reportSkeleton}Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
${BOUND_METHOD_MARKER}
${boundMethod()}
</jyotish-skill>
<jyotish-shared-method>
${sharedConsultationMethodMarkdown()}
</jyotish-shared-method>`;
}
/** Method body + marker, without the natal Level 2 report skeleton. Window agents use this. */
export const jyotishSkillMethodCoreBlock = methodBlock("");
/** Natal chart-answering block: core method plus the Level 2 report skeleton. */
export const jyotishSkillMethodBlock = methodBlock(NATAL_REPORT_SKELETON);
/**
* Withdraw the activation tools while keeping `skill_read`.