src/server/directives.ts
Rama main · réplica de lectura del repositorio original.
export interface Directives {
model?: string;
turns?: number;
thinking?: 'adaptive' | 'enabled' | 'disabled';
effort?: 'low' | 'medium' | 'high' | 'max';
engine?: string;
/** claude-code SDK session resume for this conversation (overrides agent-config `sdkResume`). */
resume?: boolean;
/** agentx DELEGATE mode `[planner->worker]` (key `delegate`): the worker model; `model` (left side) plans. */
delegate?: string;
/** agentx ADVISOR mode `[worker<-advisor]` (key `advisor`): the advisor model; `model` (left side) works. */
advisor?: string;
/** `[off]` / `[mode:off]`: clears a sticky delegate/advisor mode. Consumed by streamChat, never stored. */
modeOff?: boolean;
}
export interface ParsedPrompt {
prompt: string;
directives: Directives;
/** An EXPLICIT model selection that no alias and no registered engine could resolve. Reported
* instead of applied: dropping it (the old behaviour) ran the turn on `config.model` — a
* DIFFERENT model than the caller asked for, with nothing but a `console.warn` to show for it.
* The caller turns this into a turn error; see `streamChat`. */
unresolvedModel?: string;
}
// The default model lives in src/shared/models.ts so the client header names the same one.
export { DEFAULT_MODEL } from '../shared/models';
// Canonical model aliases + label (src/server/model-aliases.ts — derived from ai.libx.js's catalog).
// Re-exported here so the rest of shraga keeps importing model helpers from one place.
export { MODEL_ALIASES, modelShortLabel } from './model-aliases.ts';
import { MODEL_ALIASES } from './model-aliases.ts';
const DIRECTIVE_RE = /^\s*\[([^\]]*)\]\s*([\s\S]*)/;
const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine', 'resume', 'delegate', 'advisor', 'mode'];
/** `a->b` (delegate) / `a<-b` (advisor) — agentx's two-model mode grammar; the left side may be empty. */
function splitModePair(token: string): { left: string; op: '->' | '<-'; right: string } | null {
const i = token.search(/->|<-/);
if (i === -1) return null;
const right = token.slice(i + 2).trim();
return right ? { left: token.slice(0, i).trim(), op: token.slice(i, i + 2) as '->' | '<-', right } : null;
}
/** MODEL_ALIASES only covers the bare Anthropic shorthands. A `provider/model` id
* (`cursor/composer-2.5`, `openai/gpt-5.6`) is already concrete — gating it on the alias table
* silently dropped it and ran the instance default instead. As a bare positional it is honoured
* only when a REGISTERED engine actually advertises it (see the resolver below) — an arbitrary
* `[src/foo.ts]` stays prompt text, which is what a second bracket group must not swallow. */
const isQualifiedModel = (v: string) => /^[a-z0-9._-]+\/[a-z0-9./_-]+$/.test(v);
/** Resolves a model token the alias table doesn't know against the REGISTERED engines' own model
* lists, and reports which engine owns it. Without this, `[composer-2.5]` (an add-on engine's model) was
* warned about and dropped, so the turn silently ran on the previous engine/model. Injected by
* `initEngines()` — directives.ts stays pure and dependency-free for CE and for tests. */
export type ModelResolver = (token: string) => { model: string; engine?: string } | null;
let modelResolver: ModelResolver | null = null;
export function setModelResolver(fn: ModelResolver | null): void { modelResolver = fn; }
/** Alias table first (canonical shorthands win), then the engine registry. Exported because the
* client's directive editor needs the SAME verdict on "is this bare token a model?" as the parser —
* a second opinion there would strip a token the server still reads, or keep one it doesn't. */
export function resolveModelAlias(token: string): { model: string; engine?: string } | null {
const v = token.trim().toLowerCase();
if (MODEL_ALIASES[v]) return { model: MODEL_ALIASES[v] };
return modelResolver?.(v) ?? null;
}
function resolveModelToken(d: Directives, val: string): boolean {
const hit = resolveModelAlias(val);
if (!hit) return false;
d.model = hit.model;
// An engine-owned model implies its engine — but never override an explicit `[engine:x]`.
if (hit.engine && !d.engine) d.engine = hit.engine;
return true;
}
/** Does a bracket group look like directives (vs. prompt text that happens to start with `[`)?
* Every token must be a known key:value or a known positional, else we leave the group alone. */
export function isDirectiveGroup(raw: string): boolean {
const tokens = raw.split(',').map((t) => t.trim()).filter(Boolean);
if (!tokens.length) return false;
return tokens.every((t) => {
const colonIdx = t.indexOf(':');
if (colonIdx !== -1) return DIRECTIVE_KEYS.includes(t.slice(0, colonIdx).trim().toLowerCase());
const v = t.toLowerCase();
const pair = splitModePair(v);
if (pair) return (!pair.left || !!resolveModelAlias(pair.left)) && !!resolveModelAlias(pair.right);
if (v === 'off') return true;
return !!MODEL_ALIASES[v] || !!modelResolver?.(v) || /^\d+$/.test(v) || ['think', 'adaptive', 'nothink', 'nothinking'].includes(v);
});
}
/** Splits the leading `[..]` directive groups off a prompt — the single answer to "where do the
* directives end". Consumes EVERY consecutive leading group, not just the first: a prompt may
* legitimately open with `[engine:x,model:y] [turns:120] …`, and a single-group scan silently
* dropped the second (a schedule pinned to opus quietly ran on the config default for three days).
*
* `strict` requires every group — the first included — to actually look like directives. The parser
* stays lenient there (long-standing contract: `[unknown] hi` strips the group and warns), but an
* EDITOR rewriting the directive in place must never swallow a prompt whose body legitimately opens
* with bracketed prose. */
export function splitDirectiveGroups(text: string, opts?: { strict?: boolean }): { groups: string[]; body: string } {
let rest = text;
const groups: string[] = [];
for (;;) {
const m = rest.match(DIRECTIVE_RE);
if (!m) break;
const g = m[1].trim();
if ((groups.length || opts?.strict) && g && !isDirectiveGroup(g)) break;
groups.push(g);
rest = m[2];
}
return { groups, body: rest };
}
export function parseDirectives(text: string): ParsedPrompt {
const { groups, body: rest } = splitDirectiveGroups(text);
if (!groups.length) return { prompt: text, directives: {} };
const raw = groups.filter(Boolean).join(',');
const prompt = rest.trim();
if (!raw) return { prompt, directives: {} };
const directives: Directives = {};
let positionalIndex = 0;
let unresolvedModel: string | undefined;
const tokens = raw.split(',');
// Is this group PROVABLY a directive group (some token is a known `key:value`)? A bare positional
// that resolves to nothing is otherwise indistinguishable from prose — `[some bracketed text] hi`
// is a legitimate prompt and must never fail a turn. With a keyed sibling present the group is
// unambiguously directives, so an unresolvable leading model token there IS a dropped selection.
const provenDirectiveGroup = tokens.some((t) => {
const i = t.indexOf(':');
return i !== -1 && DIRECTIVE_KEYS.includes(t.slice(0, i).trim().toLowerCase());
});
for (const token of tokens) {
const t = token.trim();
if (!t) continue;
const colonIdx = t.indexOf(':');
if (colonIdx !== -1) {
const key = t.slice(0, colonIdx).trim().toLowerCase();
const val = t.slice(colonIdx + 1).trim().toLowerCase();
unresolvedModel = applyDirective(directives, key, val) ?? unresolvedModel;
} else {
const val = t.toLowerCase();
const pair = splitModePair(val);
if (pair) {
// Both sides must resolve: a half-resolved pair would silently run a mode on the wrong model.
const worker = resolveModelAlias(pair.right);
if (worker && (!pair.left || resolveModelToken(directives, pair.left))) {
if (pair.op === '->') directives.delegate = worker.model; else directives.advisor = worker.model;
} else unresolvedModel ??= t;
positionalIndex++;
continue;
}
if (val === 'off') { directives.modeOff = true; positionalIndex++; continue; }
if (positionalIndex === 0 && resolveModelToken(directives, val)) {
// handled
} else if (positionalIndex <= 1 && /^\d+$/.test(val)) {
directives.turns = parseInt(val, 10);
} else if (['think', 'adaptive'].includes(val)) {
directives.thinking = 'adaptive';
} else if (['nothink', 'nothinking'].includes(val)) {
directives.thinking = 'disabled';
} else if (positionalIndex === 0) {
if (provenDirectiveGroup && !isQualifiedModel(val)) unresolvedModel ??= t;
console.warn(isQualifiedModel(val)
? `[directives] Provider-qualified model needs key form: "[model:${t}]"`
: `[directives] Unknown model alias: "${t}"`);
}
positionalIndex++;
}
}
return { prompt, directives, unresolvedModel };
}
/** Returns the token of an EXPLICIT model selection it could not resolve, else undefined. */
function applyDirective(d: Directives, key: string, val: string): string | undefined {
switch (key) {
case 'model':
if (resolveModelToken(d, val)) break;
// A `provider/model` id is already concrete — hand it to the engine, which owns the verdict.
if (isQualifiedModel(val)) { d.model = val; break; }
// Nothing knows this token. `[model:x]` is unambiguously a selection (never prose), so the
// turn must not quietly continue on the instance default model.
return val;
case 'turns':
if (/^\d+$/.test(val)) d.turns = parseInt(val, 10);
else console.warn(`[directives] Invalid turns value: "${val}"`);
break;
case 'thinking':
case 'think':
if (['adaptive', 'enabled', 'disabled'].includes(val)) d.thinking = val as Directives['thinking'];
else console.warn(`[directives] Invalid thinking value: "${val}"`);
break;
case 'effort':
if (['low', 'medium', 'high', 'max'].includes(val)) d.effort = val as Directives['effort'];
else console.warn(`[directives] Invalid effort value: "${val}"`);
break;
case 'engine':
d.engine = val;
break;
case 'delegate':
case 'advisor': {
const hit = resolveModelAlias(val);
if (hit) d[key] = hit.model;
else if (isQualifiedModel(val)) d[key] = val;
else return val;
break;
}
case 'mode':
if (val === 'off') d.modeOff = true;
else console.warn(`[directives] Invalid mode value: "${val}"`);
break;
case 'resume':
if (['on', 'true', '1'].includes(val)) d.resume = true;
else if (['off', 'false', '0'].includes(val)) d.resume = false;
else console.warn(`[directives] Invalid resume value: "${val}"`);
break;
default:
console.warn(`[directives] Unknown directive key: "${key}"`);
}
return undefined;
}