first commit
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user