Files
2026-09-05 00:27:41 +08:00

357 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EPSE tool-call regeneration guard — Host-side Cordis plugin.
*
* When the model writes an EPSE tool-call frame (an opening tag and a closing
* tag, paired) into its TEXT reply instead of issuing a native tool call, this
* guard treats that response as a failed model request and lets the agent loop
* retry it in place.
*
* Mechanism (two halves of one native path, no deletion and no re-prompting):
*
* 1. `llm/stream` — the guard wraps the provider stream, watches only the
* model's text deltas, and when the frame appears it swaps the terminal
* `finish` chunk for a `{ kind: 'error' }` finish carrying
* {@link FAILURE_CODE}. Because the attempt now ends in failure, the loop
* never appends an `assistant/message` for it: the malformed reply never
* reaches the model-visible surface, so nothing has to be removed later.
*
* 2. `agent/request-error` — the guard claims that exact failure code, records
* the durable `llm/retry` / `llm/retry-started` pair, and returns
* `{ kind: 'retry' }`. The loop re-requests inside the SAME turn and step
* from the unchanged derived history, so the original user request is not
* replayed and no second user bubble appears. The `llm/retry` record also
* makes the conversation UI reset that step's assistant node, so the
* malformed reply disappears from the transcript and the regenerated reply
* renders in its place.
*
* Detection scope: ONLY model-generated text chunks. It never inspects written
* files, tool results, tool arguments, reasoning, or log-only events.
*
* Eligibility is judged from request DATA, never from dsh-llm's process-local
* `isAgentLoopRequest` marker — see {@link apply}'s `eligible`. A plugin
* installed by path resolves its own dsh-llm copy, so that marker's WeakSet is
* not the one the loop wrote to and the predicate is false for every real
* request.
*/
import z from '@deepseek-ai/schemastery';
export const name = 'kevin-dsh-enhance-guard';
export const inject = ['sessions', 'agents'];
/** Provider-neutral failure code this guard mints and exclusively claims. */
const FAILURE_CODE = 'EPSE_TOOL_CALL_FRAME';
/** Canonical policy identity separating this guard's retry chains from real ones. */
const POLICY_KEY = 'kevin-dsh-enhance-guard/v1';
/** Human-readable failure recorded with every claimed attempt. */
const FAILURE_MESSAGE = 'model emitted an EPSE tool-call frame as text instead of a native tool call';
/** Forced regenerations allowed per step when the config does not say otherwise. */
const DEFAULT_MAX_PER_STEP = 2;
/** User-editable settings namespace for the two knobs (插件配置 page). */
const SETTINGS_NAMESPACE = 'kevin-dsh-enhance-guard';
/** Schema of the user-owned section, layered over the composition entry. */
const SETTINGS_SCHEMA = z.object({
maxRegenerationsPerTurn: z.number().step(1).min(1).default(DEFAULT_MAX_PER_STEP),
targetProviders: z.array(z.string()).default([]),
});
export function apply(ctx, config) {
const entry = config || {};
// Composition entry stays the default source; the settings card swaps in a
// resolved-settings thunk once a settings service is mounted.
let configSource = () => entry;
/** Resolve the live knobs from the current source (entry or user settings). */
const effectiveConfig = () => {
const cfg = configSource() || {};
return {
// Provider/model routes to restrict to; empty set = apply to ALL agents.
targets: new Set(Array.isArray(cfg.targetProviders) ? cfg.targetProviders : []),
// Max forced regenerations per (session, turn, step) to avoid infinite loops.
maxPerStep:
typeof cfg.maxRegenerationsPerTurn === 'number' && cfg.maxRegenerationsPerTurn >= 1
? Math.floor(cfg.maxRegenerationsPerTurn)
: DEFAULT_MAX_PER_STEP,
};
};
// Expose the knobs as a user-editable settings section so the 插件配置 page
// can render a card for them; the base layer is this composition entry.
// Register through the settings service the SAME way the stock plugins do
// (agent-loop, web-search, terminal) — `ctx.inject(["settings"], ...)` gives
// a child context whose `.settings` is the live SettingsProvider; a plain
// `ctx.get("settings")` is not how a plugin context reaches it.
try {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.installSection(
ctx,
SETTINGS_NAMESPACE,
SETTINGS_SCHEMA,
entry,
{
setSource: (current) => {
configSource = current;
},
onChange: () => {},
},
);
});
} catch (error) {
ctx.logger?.warn?.(
'kevin-dsh-enhance-guard: settings section unavailable; using composition config: %o',
error,
);
}
/** session -> Map<`turn:step`, forced failures already injected>. */
const forced = new WeakMap();
const normalize = (s) => String(s == null ? '' : s).normalize('NFKC').toLowerCase();
// Rule: the text contains BOTH an EPSE opening tag and an EPSE closing tag.
// NFKC maps full-width forms (<, EPSE) to ASCII; lowercasing handles case.
// Opening: <|epse or <epse; Closing: </|epse or </epse.
const hasEpseFrame = (text) => {
const t = normalize(text);
return /<\|?epse/.test(t) && /<\/\|?epse/.test(t);
};
const routeInScope = (provider, model) => {
const { targets } = effectiveConfig();
return targets.size === 0 || targets.has(provider) || targets.has(model);
};
const agentInScope = (agent) => {
const { targets } = effectiveConfig();
if (targets.size === 0) return true; // empty = every agent
const opt = (agent && agent.options) || {};
return targets.has(opt.provider) || targets.has(opt.model);
};
/** The turn/step currently open in a session log, or undefined outside one. */
const openStep = (session) => {
const events =
typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events;
if (!Array.isArray(events)) return undefined;
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
if (event.type === 'step/start') return { turn: event.data.turn, step: event.data.step };
if (event.type === 'step/end' || event.type === 'turn/end' || event.type === 'turn/start') return;
}
return undefined;
};
const budget = (session) => {
let counts = forced.get(session);
if (!counts) {
counts = new Map();
forced.set(session, counts);
}
return counts;
};
const mintRetryId = () =>
`epse-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
/** Accumulate one chunk's model-facing TEXT, ignoring every other block kind. */
const textOf = (chunk) => {
if (chunk.type === 'text-delta') return typeof chunk.text === 'string' ? chunk.text : '';
if (chunk.type === 'block-end') {
const block = chunk.block;
return block && block.type === 'text' && typeof block.text === 'string' ? block.text : '';
}
return '';
};
/**
* Whether a request's `messages` array IS the session's current derived
* history — the same array contents, element for element.
*
* `deriveMessages()` returns a fresh array of SHARED, deep-frozen `Message`
* objects, and the loop passes that array straight into its request, so
* reference equality per element holds for a loop-built request and fails for
* a hand-assembled message list. Comparing objects (not their content) also
* keeps this O(n) over a handful of pointers instead of a deep walk.
* @param session - the live session the request names.
* @param messages - the request's message list.
* @returns whether the list is exactly the derived history.
*/
const isDerivedHistory = (session, messages) => {
if (!Array.isArray(messages)) return false;
const derived = session.deriveMessages();
if (derived.length !== messages.length) return false;
for (let index = 0; index < derived.length; index++) {
if (derived[index] !== messages[index]) return false;
}
return true;
};
/**
* Whether this request is an ordinary in-loop conversation request, judged
* WITHOUT dsh-llm's `isAgentLoopRequest` marker.
*
* That marker is a WeakSet living inside ONE dsh-llm module instance. A plugin
* installed by path (`link:` / junction) resolves its own copy of dsh-llm
* through its own `node_modules`, so the loop's mark is written into a
* different WeakSet than the one this code would read: the predicate answers
* false for EVERY real request and the guard silently never fires. Object
* identity cannot be shared across duplicated module instances, so this
* checks the two properties dsh-llm documents for a loop-built request
* instead — both plain data, both duplication-proof:
*
* - it arrives deep-frozen (`GenerateOptions` docs: mutation throws);
* - its content is a pure function of the session log, so its `messages`
* array is that session's current derived history.
*
* The remaining conditions are the guard's own preconditions: an auxiliary
* call (`purpose`) is not its business, and the route must be in scope.
*
* Any throw means "not eligible": a guard must never break a model call to
* decide it should not have guarded it.
* @param options - the request observed at the `llm/stream` waterfall.
* @param session - the live session the request names.
* @returns whether the guard may force this attempt to fail.
*/
const eligible = (options, session) => {
try {
return (
options.purpose === undefined &&
Object.isFrozen(options) &&
isDerivedHistory(session, options.messages) &&
routeInScope(options.provider, options.model)
);
} catch (error) {
ctx.logger?.warn?.('kevin-dsh-enhance-guard: eligibility check failed: %o', error);
return false;
}
};
/**
* Wrap one provider stream and end a framed attempt as a request failure.
* @param options - the frozen request being streamed.
* @param next - the downstream stream this listener wraps.
*/
async function* guardStream(options, next) {
const source = next();
// Only ordinary loop-built conversation requests are eligible: auxiliary
// calls (compaction, session titles) and hand-built one-shots are not this
// guard's business, and neither owns a turn/step to retry inside.
const session =
options.sessionId === undefined ? undefined : ctx.sessions.get(options.sessionId);
if (session === undefined || !eligible(options, session)) {
yield* source;
return;
}
const position = openStep(session);
if (position === undefined) {
yield* source;
return;
}
const counts = budget(session);
const key = `${position.turn}:${position.step}`;
const used = counts.get(key) || 0;
// Budget exhausted: let the reply land normally rather than failing the turn.
if (used >= effectiveConfig().maxPerStep) {
yield* source;
return;
}
let text = '';
let framed = false;
let finished = false;
const failureFinish = () => ({
type: 'finish',
reason: { kind: 'error', failure: { message: FAILURE_MESSAGE, code: FAILURE_CODE } },
});
for await (const chunk of source) {
if (!framed) {
const added = textOf(chunk);
if (added !== '') {
text += added;
framed = hasEpseFrame(text);
}
}
// Replace only a plain successful finish. `tool-calls` means the model DID
// issue a native call, which is the correct format and never this guard's
// business; an already-failed, aborted, or truncated attempt keeps its own
// outcome and its own recovery owner.
if (framed && chunk.type === 'finish' && chunk.reason?.kind === 'stop') {
counts.set(key, used + 1);
yield failureFinish();
return;
}
if (chunk.type === 'finish') finished = true;
yield chunk;
}
// A stream that ended without any terminal finish would be assembled as a
// successful `stop`, landing the malformed reply. Close it as this guard's
// failure instead.
if (framed && !finished) {
counts.set(key, used + 1);
yield failureFinish();
}
}
ctx.on('llm/stream', (options, next) => guardStream(options, next));
/**
* Claim the guard's own failure code and retry the step from unchanged history.
* Any other failure is delegated downstream verbatim.
*/
ctx.on(
'agent/request-error',
async (payload, next) => {
const { agent, turn, step, provider, failure } = payload;
if (!failure || failure.code !== FAILURE_CODE) return next();
if (!agent || !agentInScope(agent)) return next();
const session = agent.session;
if (!session) return next();
// Durably record the forced retry so the conversation UI resets this
// step's assistant node (dropping the malformed reply from the
// transcript) and renders the regenerated reply in its place.
try {
const sessionEvents =
typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events;
if (!Array.isArray(sessionEvents) || typeof sessionEvents.findLast !== 'function') {
ctx.logger?.warn?.('kevin-dsh-enhance-guard: session events unavailable; skipping durable retry record');
return { kind: 'retry' };
}
const prior = sessionEvents.findLast(
(event) =>
event.type === 'llm/retry' &&
event.data.turn === turn &&
event.data.step === step &&
event.data.provider === provider &&
event.data.policyKey === POLICY_KEY,
);
const retry = (prior?.data.retry ?? 0) + 1;
const retryId = prior?.data.retryId ?? mintRetryId();
session.append('llm/retry', {
retryId,
turn,
step,
provider,
mode: 'normal',
policyKey: POLICY_KEY,
retry,
maxRetries: Math.max(effectiveConfig().maxPerStep, retry),
delayMs: 0,
failure,
});
session.append('llm/retry-started', { retryId, turn, step, retry });
} catch (error) {
// The durable notice is presentation, not correctness: regenerate anyway.
ctx.logger?.warn?.('kevin-dsh-enhance-guard: could not record the forced retry: %o', error);
}
return { kind: 'retry' };
},
{ prepend: true },
);
}