357 lines
14 KiB
JavaScript
357 lines
14 KiB
JavaScript
/**
|
||
* 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 },
|
||
);
|
||
}
|