Compare commits

..

3 Commits

Author SHA1 Message Date
524a0deaa1 feat(coder): add model resolution core + multi-batch matcher
Model resolution (from oh-my-openagent/model-core): 6-step priority
resolution pipeline (UI select -> user config -> category default ->
user fallback -> policy chain -> system default), provider fallback
chains, fuzzy model matching, error classification, provider-specific
model ID transforms. 14 files, zero runtime deps.

Multi-batch matcher (from boocontext-audit): 6 batch types
(Observational, Actionable, PreviouslyApplied, Disambiguation,
ResponseAnalysis, LowCriticality) for behavioral guideline evaluation.
RelationalResolver with iterative convergence (DEPENDS_ON,
PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES). SchematicGenerator
abstract class with retry and execution plans. 4 files.
2026-06-08 00:17:55 +00:00
a7a40c5b46 feat(coder): add hashline editing core + wire audit hooks into dispatch pipeline
Hashline editing: content-hash anchors for edit_file stale-patch detection.
Pure-JS xxHash32, line hash computation, validation with HashlineMismatchError,
256-entry hash dictionary. 6 files in apps/coder/src/services/hashline/.

Audit hooks: emitHook('tool.execute.after') wired in frame-emitter.ts for
completed/failed tool results. emitHook('turn.end') wired at terminal points
in dispatcher.ts (all 5 run functions: native, external, opencode, warm ACP,
claude SDK). Fire-and-forget, non-blocking.
2026-06-07 23:17:47 +00:00
e5183cc71b feat(agents): differentiate tool restrictions per agent role
Each of 9 agents now has a unique purpose-scoped tool whitelist:
- Security Auditor: 10 tools (tightest, static analysis only)
- Prompt Builder: 5 tools (core file exploration + overview)
- Code Reviewer/Debugger/Recon: 18 tools each (different codecontext subsets)
- Refactorer/Planner: 19 tools each (full codecontext, planner narrower fs)
- Architect: 22 tools (only one with web_search + web_fetch)
- Builder: 25 tools (unchanged, only write-capable)
2026-06-07 23:17:38 +00:00
27 changed files with 2610 additions and 10 deletions

View File

@@ -0,0 +1,204 @@
/**
* Schematic generator for behavioral guideline batches.
*
* Port of boocontext-audit/src/generation.ts — abstract LLM batch caller
* with temperature retry and structured output per batch type.
*/
import { type GenerationInfo } from './matching.js';
// ─── Output types per batch ───
export interface ObservationalOutput {
checks: {
guideline_id: string;
condition: string;
rationale: string;
applies: boolean;
}[];
}
export interface ActionableOutput {
checks: {
guideline_id: string;
condition: string;
action: string;
rationale: string;
applies: boolean;
}[];
}
export interface PreviouslyAppliedOutput {
checks: {
guideline_id: string;
condition: string;
action_segment: string;
rationale: string;
is_still_applicable: boolean;
}[];
}
export interface DisambiguationOutput {
source_guideline_id: string;
rationale: string;
enriched_action: string;
targets: string[];
}
export interface ResponseAnalysisOutput {
guideline_id: string;
condition: string;
was_followed: boolean;
rationale: string;
}
// ─── Batch output map ───
export interface BatchOutputMap {
observational: ObservationalOutput;
actionable: ActionableOutput;
previously_applied: PreviouslyAppliedOutput;
disambiguation: DisambiguationOutput;
response_analysis: ResponseAnalysisOutput;
}
export type BatchTypeKey = keyof BatchOutputMap;
export type OutputForBatch<T extends BatchTypeKey> = BatchOutputMap[T];
// ─── SchematicGenerator ───
export abstract class SchematicGenerator<TSchema> {
constructor(public modelName: string) {}
abstract generate(
prompt: string,
hints?: Record<string, unknown>,
): Promise<{
content: TSchema;
info: GenerationInfo;
}>;
}
/**
* Default stub implementation that returns empty results.
* Replace with a real LLM caller in production.
*/
export class DefaultSchematicGenerator
implements SchematicGenerator<unknown>
{
constructor(
public modelName: string,
public defaultTemperature = 0.7,
) {}
async generate(
_prompt: string,
hints?: Record<string, unknown>,
): Promise<{ content: unknown; info: GenerationInfo }> {
const temperature = (hints?.temperature as number) ?? this.defaultTemperature;
return {
content: {},
info: {
model: this.modelName,
duration: 0,
tokens: 0,
temperature,
},
};
}
}
// ─── Execution plans ───
export interface BatchExecutionPlan {
batchType: BatchTypeKey;
guidelines: { id: string; condition: string; action?: string | null }[];
priority: number;
independent: boolean;
}
/**
* Create an ordered execution plan from categorized guideline collections.
* Groups are sorted by priority: previously_applied (fastest) first,
* then observational, actionable, disambiguation, low-criticality last.
*/
export function createExecutionPlan(
observational: { id: string; condition: string }[],
actionable: { id: string; condition: string; action: string }[],
previouslyApplied: { id: string; condition: string; action?: string | null }[],
disambiguationGroups: { source: string; targets: string[]; enrichedAction: string }[],
lowCriticality: { id: string; condition: string }[],
): BatchExecutionPlan[] {
const plans: BatchExecutionPlan[] = [];
if (observational.length > 0) {
plans.push({
batchType: 'observational',
guidelines: observational.map((g) => ({ id: g.id, condition: g.condition })),
priority: 1,
independent: true,
});
}
if (actionable.length > 0) {
plans.push({
batchType: 'actionable',
guidelines: actionable.map((g) => ({
id: g.id,
condition: g.condition,
action: g.action,
})),
priority: 2,
independent: true,
});
}
if (previouslyApplied.length > 0) {
plans.push({
batchType: 'previously_applied',
guidelines: previouslyApplied.map((g) => ({
id: g.id,
condition: g.condition,
action: g.action,
})),
priority: 0,
independent: true,
});
}
if (disambiguationGroups.length > 0) {
plans.push({
batchType: 'disambiguation',
guidelines: disambiguationGroups.map((g) => ({
id: g.source,
condition: g.enrichedAction,
})),
priority: 3,
independent: true,
});
}
if (lowCriticality.length > 0) {
plans.push({
batchType: 'observational',
guidelines: lowCriticality.map((g) => ({ id: g.id, condition: g.condition })),
priority: 10,
independent: true,
});
}
return plans.sort((a, b) => a.priority - b.priority);
}
/**
* Compute retry temperatures: base + 0.2 * attempt.
* Provides progressive temperature increases for failed calls.
*/
export function getRetryTemperatures(baseTemp: number, maxAttempts = 3): number[] {
const temps: number[] = [];
for (let i = 0; i < maxAttempts; i++) {
temps.push(baseTemp + i * 0.2);
}
return temps;
}

View File

@@ -0,0 +1,77 @@
/**
* Behavioral engine — multi-batch matcher and relational resolver.
*
* Import from the existing guideline-service.ts:
* import { MultiBatchMatcher } from './behavioral/matching.js';
* import { RelationalResolver } from './behavioral/resolver.js';
*/
// matching.ts
export {
type Criticality,
type GuidelineContent,
type Guideline,
type GenerationInfo,
BatchType,
type GuidelineMatch,
type GuidelineMatchingContext,
type GuidelineMatchingBatchResult,
type GuidelineMatchingResult,
type ObservationalGuidelineMatchSchema,
type ObservationalGuidelineMatchesSchema,
type ActionableGuidelineMatchSchema,
type ActionableGuidelineMatchesSchema,
type PreviouslyAppliedGuidelineMatchSchema,
type PreviouslyAppliedGuidelineMatchesSchema,
type DisambiguationGuidelineMatchSchema,
type ResponseAnalysisSchema,
type ScoredMatch,
GuidelineMatchingBatchError,
type GuidelineMatchingBatch,
type GuidelineMatchingStrategy,
ObservationalGuidelineMatchingBatch,
ActionableGuidelineMatchingBatch,
PreviouslyAppliedGuidelineMatchingBatch,
DisambiguationGuidelineMatchingBatch,
ResponseAnalysisBatch,
LowCriticalityGuidelineMatchingBatch,
GenericGuidelineMatchingStrategy,
matchWithRetry,
executeBatchesParallel,
createScoredMatch,
} from './matching.js';
// resolver.ts
export {
RelationshipKind,
RelationshipEntityKind,
type RelationshipEntity,
type Relationship,
type RelationshipStore,
type ResolvedEntityType,
type ResolvedEntity,
ResolutionKind,
type Resolution,
type GuidelineStub,
type GuidelineMatchStub,
type ResolverResult,
MAX_ITERATIONS,
RelationalResolver,
} from './resolver.js';
// generation.ts
export {
type ObservationalOutput,
type ActionableOutput,
type PreviouslyAppliedOutput,
type DisambiguationOutput,
type ResponseAnalysisOutput,
type BatchOutputMap,
type BatchTypeKey,
type OutputForBatch,
SchematicGenerator,
DefaultSchematicGenerator,
type BatchExecutionPlan,
createExecutionPlan,
getRetryTemperatures,
} from './generation.js';

View File

@@ -0,0 +1,435 @@
/**
* Multi-batch matcher for behavioral guidelines.
*
* Port of boocontext-audit/src/matching.ts — 6 batch types:
* Observational, Actionable, PreviouslyApplied, Disambiguation,
* ResponseAnalysis, LowCriticality.
*/
// ─── Guideline types (compatible with guideline-service.ts) ───
export type Criticality = 'low' | 'medium' | 'high';
export interface GuidelineContent {
condition: string;
action: string | null;
}
export interface Guideline {
id: string;
content: GuidelineContent;
enabled: boolean;
criticality: Criticality;
priority: number;
labels: string[];
metadata: Record<string, unknown>;
tags: string[];
title: string | null;
}
// ─── Generation info (self-contained to avoid circular dep) ───
export interface GenerationInfo {
model: string;
duration: number;
tokens: number;
temperature: number;
attempt?: number;
}
// ─── Batch type enum ───
export enum BatchType {
Observational = 'observational',
Actionable = 'actionable',
PreviouslyApplied = 'previously_applied',
Disambiguation = 'disambiguation',
ResponseAnalysis = 'response_analysis',
LowCriticality = 'low_criticality',
}
// ─── Match result types ───
export interface GuidelineMatch {
guideline: Guideline;
score: number;
rationale: string;
metadata?: Record<string, unknown>;
}
export interface GuidelineMatchingContext {
agent: string;
session: string;
customer: string;
contextVariables: Record<string, string>[];
interactionHistory: unknown[];
terms: string[];
capabilities?: string[];
stagedEvents?: unknown[];
activeJourneys?: unknown[];
journeyPaths?: Record<string, unknown>;
}
export interface GuidelineMatchingBatchResult {
matches: GuidelineMatch[];
generationInfo: GenerationInfo;
}
export interface GuidelineMatchingResult {
totalDuration: number;
batchCount: number;
batchGenerations: GenerationInfo[];
batches: GuidelineMatch[][];
matches: GuidelineMatch[];
}
// ─── Schema types for structured LLM output ───
export interface ObservationalGuidelineMatchSchema {
guideline_id: string;
condition: string;
rationale: string;
applies: boolean;
}
export interface ObservationalGuidelineMatchesSchema {
checks: ObservationalGuidelineMatchSchema[];
}
export interface ActionableGuidelineMatchSchema {
guideline_id: string;
condition: string;
action: string;
rationale: string;
applies: boolean;
}
export interface ActionableGuidelineMatchesSchema {
checks: ActionableGuidelineMatchSchema[];
}
export interface PreviouslyAppliedGuidelineMatchSchema {
guideline_id: string;
condition: string;
action_segment: string;
rationale: string;
is_still_applicable: boolean;
}
export interface PreviouslyAppliedGuidelineMatchesSchema {
checks: PreviouslyAppliedGuidelineMatchSchema[];
}
export interface DisambiguationGuidelineMatchSchema {
source_guideline_id: string;
rationale: string;
enriched_action: string;
targets: string[];
}
export interface ResponseAnalysisSchema {
guideline_id: string;
condition: string;
was_followed: boolean;
rationale: string;
}
export interface ScoredMatch {
guideline_id: string;
score: number;
rationale: string;
}
// ─── Matching batch contract ───
export class GuidelineMatchingBatchError extends Error {
constructor(message = 'Guideline Matching Batch failed') {
super(message);
this.name = 'GuidelineMatchingBatchError';
}
}
export interface GuidelineMatchingBatch {
readonly size: number;
process(): Promise<GuidelineMatchingBatchResult>;
}
export interface GuidelineMatchingStrategy {
createMatchingBatches(
guidelines: Guideline[],
context: GuidelineMatchingContext,
): GuidelineMatchingBatch[];
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[];
}
// ─── Batch implementations ───
function scoreFromApplies(applies: boolean): number {
return applies ? 10 : 1;
}
export class ObservationalGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (g.content.action !== null && g.content.action !== undefined) continue;
matches.push({
guideline: g,
score: 10,
rationale: `Observational batch evaluated: "${g.content.condition}"`,
metadata: { batch_type: BatchType.Observational },
});
}
return { matches, generationInfo: this.generationInfo };
}
}
export class ActionableGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (g.content.action === null || g.content.action === undefined) continue;
if (g.content.action === '') continue;
matches.push({
guideline: g,
score: 10,
rationale: `Actionable batch evaluated: when "${g.content.condition}", then "${g.content.action}"`,
metadata: { batch_type: BatchType.Actionable },
});
}
return { matches, generationInfo: this.generationInfo };
}
}
export class PreviouslyAppliedGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public priorMatches: GuidelineMatch[],
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const alreadyApplied = new Set(
this.priorMatches.filter((m) => m.score >= 10).map((m) => m.guideline.id),
);
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (alreadyApplied.has(g.id)) {
matches.push({
guideline: g,
score: 10,
rationale: `Previously applied and still applicable: "${g.content.condition}"`,
metadata: { batch_type: BatchType.PreviouslyApplied },
});
}
}
return { matches, generationInfo: this.generationInfo };
}
}
export class DisambiguationGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public disambiguationGuideline: Guideline,
public targets: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return 1 + this.targets.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
matches.push({
guideline: this.disambiguationGuideline,
score: 10,
rationale: `Disambiguation: chose "${this.disambiguationGuideline.content.condition}" over targets`,
metadata: {
batch_type: BatchType.Disambiguation,
disambiguation: {
targets: this.targets.map((t) => t.id),
enriched_action: this.disambiguationGuideline.content.action ?? '',
},
},
});
return { matches, generationInfo: this.generationInfo };
}
}
export class ResponseAnalysisBatch {
constructor(
public guidelineMatches: GuidelineMatch[],
public context: Record<string, unknown>,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelineMatches.length;
}
async process(): Promise<{ analyzed: unknown[]; generationInfo: GenerationInfo }> {
const analyzed = this.guidelineMatches.map((m) => ({
guideline: m.guideline,
is_previously_applied: m.score >= 10,
}));
return { analyzed, generationInfo: this.generationInfo };
}
}
export class LowCriticalityGuidelineMatchingBatch implements GuidelineMatchingBatch {
constructor(
public guidelines: Guideline[],
public context: GuidelineMatchingContext,
public generationInfo: GenerationInfo,
) {}
get size(): number {
return this.guidelines.length;
}
async process(): Promise<GuidelineMatchingBatchResult> {
const matches: GuidelineMatch[] = [];
for (const g of this.guidelines) {
if (g.criticality !== 'low') continue;
matches.push({
guideline: g,
score: g.content.action ? 10 : 1,
rationale: `Low-criticality batch: "${g.content.condition}"`,
metadata: { batch_type: BatchType.LowCriticality },
});
}
return { matches, generationInfo: this.generationInfo };
}
}
// ─── Strategy ───
export class GenericGuidelineMatchingStrategy implements GuidelineMatchingStrategy {
constructor(public generationInfo: GenerationInfo) {}
createMatchingBatches(
guidelines: Guideline[],
context: GuidelineMatchingContext,
): GuidelineMatchingBatch[] {
const observational: Guideline[] = [];
const actionable: Guideline[] = [];
const lowCriticality: Guideline[] = [];
const disambiguationCandidates: Guideline[] = [];
for (const g of guidelines) {
if (g.criticality === 'low') {
lowCriticality.push(g);
} else if (!g.content.action) {
disambiguationCandidates.push(g);
} else if (g.content.action) {
actionable.push(g);
} else {
observational.push(g);
}
}
const batches: GuidelineMatchingBatch[] = [];
if (observational.length > 0) {
batches.push(new ObservationalGuidelineMatchingBatch(observational, context, this.generationInfo));
}
if (actionable.length > 0) {
batches.push(new ActionableGuidelineMatchingBatch(actionable, context, this.generationInfo));
}
if (lowCriticality.length > 0) {
batches.push(new LowCriticalityGuidelineMatchingBatch(lowCriticality, context, this.generationInfo));
}
return batches;
}
transformMatches(matches: GuidelineMatch[]): GuidelineMatch[] {
const seen = new Set<string>();
return matches.filter((m) => {
const key = m.guideline.id;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
}
// ─── Utilities ───
export async function matchWithRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3,
_baseTemperature = 0.7,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
// will retry
}
}
}
throw lastError;
}
export async function executeBatchesParallel(
batches: GuidelineMatchingBatch[],
_generationInfo: GenerationInfo,
): Promise<GuidelineMatchingResult> {
const start = Date.now();
const results = await Promise.all(
batches.map((batch) => matchWithRetry(() => batch.process())),
);
const allBatches = results.map((r) => r.matches);
const allMatches = allBatches.flat();
const allGenInfos = results.map((r) => r.generationInfo);
return {
totalDuration: Date.now() - start,
batchCount: batches.length,
batchGenerations: allGenInfos,
batches: allBatches,
matches: allMatches,
};
}
export function createScoredMatch(
guidelineId: string,
score: number,
rationale: string,
): ScoredMatch {
return { guideline_id: guidelineId, score, rationale };
}

View File

@@ -0,0 +1,355 @@
/**
* Relational resolver for behavioral guidelines.
*
* Port of boocontext-audit/src/resolver.ts — resolves DEPENDS_ON,
* PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES relationships
* with an iterative convergence loop.
*/
// ─── Relationship types (self-contained) ───
export enum RelationshipKind {
DEPENDS_ON = 'depends_on',
PRIORITIZES = 'prioritizes',
ENTAILS = 'entails',
TAG_ALL = 'tag_all',
TAG_PRIORITIZES = 'tag_prioritizes',
}
export enum RelationshipEntityKind {
GUIDELINE = 'guideline',
TAG = 'tag',
}
export interface RelationshipEntity {
id: string;
kind: RelationshipEntityKind;
}
export interface Relationship {
id: string;
creation_utc: string;
source: RelationshipEntity;
target: RelationshipEntity;
kind: RelationshipKind;
group_id?: string;
}
/**
* Minimal relationship store interface.
* The resolver only needs listRelationships. Implementations
* can back against files, postgres, or in-memory maps.
*/
export interface RelationshipStore {
listRelationships(
kind?: RelationshipKind,
sourceId?: string,
targetId?: string,
): Promise<Relationship[]>;
}
// ─── Resolution types ───
export type ResolvedEntityType = 'guideline' | 'journey' | 'tag';
export interface ResolvedEntity {
entityType: ResolvedEntityType;
entityId: string;
}
export enum ResolutionKind {
NONE = 'none',
UNMET_DEPENDENCY = 'unmet_dependency',
DEPRIORITIZED = 'deprioritized',
ENTAILED = 'entailed',
}
export interface Resolution {
kind: ResolutionKind;
description: string;
relationshipId?: string;
counterparts?: ResolvedEntity[];
}
export interface GuidelineStub {
id: string;
priority: number;
tags: string[];
}
export interface GuidelineMatchStub {
guideline: GuidelineStub;
}
export interface ResolverResult {
matchedIds: Set<string>;
resolutions: Map<string, Resolution[]>;
converged: boolean;
iterations: number;
}
// ─── Constants ───
export const MAX_ITERATIONS = 100;
// ─── RelationalResolver ───
export class RelationalResolver {
private store: RelationshipStore;
constructor(store: RelationshipStore) {
this.store = store;
}
async resolve(
matchedIds: Set<string>,
allGuidelines: GuidelineStub[],
): Promise<ResolverResult> {
const resolutions = new Map<string, Resolution[]>();
const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g]));
let currentIds = new Set(matchedIds);
const priorityRemoved = new Set<string>();
const entailedIds = new Set<string>();
let converged = false;
let iterations = 0;
for (iterations = 0; iterations < MAX_ITERATIONS; iterations++) {
const candidateIds = new Set(
[...currentIds].filter((id) => !priorityRemoved.has(id)),
);
const step1Ids = await this.applyDependencies(candidateIds, guidelinesById, resolutions);
const step2Ids = await this.applyPrioritization(
step1Ids,
guidelinesById,
resolutions,
priorityRemoved,
);
const step3Ids = this.applyNumericalPriority(
step2Ids,
guidelinesById,
resolutions,
priorityRemoved,
entailedIds,
);
const step4Ids = await this.applyEntailment(
step3Ids,
guidelinesById,
resolutions,
priorityRemoved,
entailedIds,
);
if (this.setsEqual(step4Ids, currentIds)) {
converged = true;
break;
}
currentIds = step4Ids;
}
for (const id of allGuidelines.map((g) => g.id)) {
if (!resolutions.has(id)) {
resolutions.set(id, [
{ kind: ResolutionKind.NONE, description: 'No relational changes' },
]);
}
}
return {
matchedIds: currentIds,
resolutions,
converged,
iterations: iterations + 1,
};
}
// ── Private steps ──
private async applyDependencies(
candidateIds: Set<string>,
_guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
): Promise<Set<string>> {
const surviving = new Set(candidateIds);
const cache = new Map<string, Relationship[]>();
for (const gid of candidateIds) {
const rels = await this.getRelationshipsFromCache(cache, gid, RelationshipKind.DEPENDS_ON);
for (const rel of rels) {
const targetId = rel.target.id;
if (!candidateIds.has(targetId)) {
surviving.delete(gid);
this.addResolution(resolutions, gid, {
kind: ResolutionKind.UNMET_DEPENDENCY,
description: `Depends on ${targetId} which is not matched`,
relationshipId: rel.id,
counterparts: [{ entityType: 'guideline' as const, entityId: targetId }],
});
break;
}
}
}
return surviving;
}
private async applyPrioritization(
candidateIds: Set<string>,
guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
priorityRemoved: Set<string>,
): Promise<Set<string>> {
const surviving = new Set(candidateIds);
const cache = new Map<string, Relationship[]>();
for (const gid of candidateIds) {
if (priorityRemoved.has(gid)) continue;
const allRels = await this.getAllRelationships(cache, gid);
const priorityRels = allRels.filter((r) => r.kind === RelationshipKind.PRIORITIZES);
for (const rel of priorityRels) {
const sourceId = rel.source.id;
if (sourceId !== gid) continue;
const targetId = rel.target.id;
if (candidateIds.has(targetId)) {
surviving.delete(targetId);
priorityRemoved.add(targetId);
this.addResolution(resolutions, targetId, {
kind: ResolutionKind.DEPRIORITIZED,
description: `Deprioritized by ${gid}`,
relationshipId: rel.id,
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
});
}
}
}
return surviving;
}
private applyNumericalPriority(
candidateIds: Set<string>,
guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
priorityRemoved: Set<string>,
entailedIds: Set<string>,
): Set<string> {
if (candidateIds.size === 0) return candidateIds;
const nonEntailed = [...candidateIds].filter((id) => !entailedIds.has(id));
const entailed = [...candidateIds].filter((id) => entailedIds.has(id));
if (nonEntailed.length === 0) return new Set(entailed);
const priorities = nonEntailed.map((id) => guidelinesById.get(id)?.priority ?? 0);
const maxPriority = Math.max(...priorities);
const surviving = new Set<string>();
for (const id of nonEntailed) {
const priority = guidelinesById.get(id)?.priority ?? 0;
if (priority >= maxPriority) {
surviving.add(id);
} else {
priorityRemoved.add(id);
this.addResolution(resolutions, id, {
kind: ResolutionKind.DEPRIORITIZED,
description: `Lower priority (${priority} < ${maxPriority})`,
});
}
}
for (const id of entailed) {
surviving.add(id);
}
return surviving;
}
private async applyEntailment(
candidateIds: Set<string>,
guidelinesById: Map<string, GuidelineStub>,
resolutions: Map<string, Resolution[]>,
priorityRemoved: Set<string>,
entailedIds: Set<string>,
): Promise<Set<string>> {
const result = new Set(candidateIds);
const cache = new Map<string, Relationship[]>();
for (const gid of candidateIds) {
if (priorityRemoved.has(gid)) continue;
const allRels = await this.getAllRelationships(cache, gid);
const entailRels = allRels.filter((r) => r.kind === RelationshipKind.ENTAILS);
for (const rel of entailRels) {
const targetId = rel.target.id;
if (!guidelinesById.has(targetId)) continue;
if (priorityRemoved.has(targetId)) continue;
if (entailedIds.has(targetId)) continue;
result.add(targetId);
entailedIds.add(targetId);
this.addResolution(resolutions, targetId, {
kind: ResolutionKind.ENTAILED,
description: `Entailed by ${gid}`,
relationshipId: rel.id,
counterparts: [{ entityType: 'guideline' as const, entityId: gid }],
});
}
}
return result;
}
// ── Cache helpers ──
private async getRelationshipsFromCache(
cache: Map<string, Relationship[]>,
gid: string,
kind: RelationshipKind,
): Promise<Relationship[]> {
const key = `${kind}:${gid}`;
if (!cache.has(key)) {
cache.set(key, await this.store.listRelationships(kind, gid));
}
return cache.get(key)!;
}
private async getAllRelationships(
cache: Map<string, Relationship[]>,
gid: string,
): Promise<Relationship[]> {
const result: Relationship[] = [];
const kinds = Object.values(RelationshipKind) as RelationshipKind[];
for (const kind of kinds) {
const rels = await this.getRelationshipsFromCache(cache, gid, kind);
const targetRels = await this.getRelationshipsFromCache(cache, `target:${gid}`, kind);
result.push(...rels, ...targetRels);
}
return result;
}
private addResolution(
resolutions: Map<string, Resolution[]>,
id: string,
resolution: Resolution,
): void {
if (!resolutions.has(id)) resolutions.set(id, []);
resolutions.get(id)!.push(resolution);
}
private setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const item of a) if (!b.has(item)) return false;
return true;
}
}

View File

@@ -30,6 +30,7 @@ import {
type TerminalMessageStatus,
} from './finalize-message.js';
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
import { emitHook } from '../plugins/host.js';
interface InferenceRunner {
enqueue: (
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
}
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
// is silently swallowed so it never blocks the dispatch flow.
function emitTurnEnd(
sessionId: string,
taskId: string,
state: string,
agent?: string | null,
model?: string | null,
outputSummary?: string,
): void {
void emitHook('turn.end', {
sessionId,
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
});
}
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
// state and publish the matching message_complete frame. Best-effort + idempotent
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
// Declared before try so the catch block can write it back on the task row.
let chatId: string | null = null;
let sessionId: string | undefined;
try {
// Mark running
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
// whose persona is stamped on the session via agent_id) or create a fresh one.
const model = task.model ?? config.DEFAULT_MODEL;
let sessionId: string;
if (task.session_id) {
sessionId = task.session_id;
} else {
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId}
`;
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
return;
}
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
} else {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId}
`;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
WHERE id = ${taskId}
`.catch(() => {});
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
}
}
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId);
return;
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
// #10: external-agent turn completed cleanly.
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
// preceded its assignment — guard so the status publish never masks the real
// error.
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
// Best-effort cleanup
await cleanupWorktree(projectPath, taskId);
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm
}
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed.
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm
}
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm
}
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed',
);
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}

View File

@@ -19,9 +19,10 @@
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { AgentEvent } from './agent-backend.js';
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import type { DcpStreamStripper } from './dcp-strip.js';
import { emitHook } from '../plugins/host.js';
export interface FrameEmitterOpts {
broker?: Broker;
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
}
break;
case 'tool_call':
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
if (canStream()) {
broker!.publishFrame(sessionId!, {
type: 'tool_call',
message_id: assistantId!,
chat_id: chatId!,
tool_call: snapshotToWireToolCall(e.toolCall),
} as WsFrame);
}
break;
case 'tool_update':
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
{
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
if (lifecycle === 'completed' || lifecycle === 'failed') {
void emitHook('tool.execute.after', {
toolName: e.toolCall.title,
args: e.toolCall.rawInput,
result: e.toolCall.rawOutput,
duration: undefined,
});
}
}
if (canStream()) {
broker!.publishFrame(sessionId!, {
type: 'tool_call',

View File

@@ -0,0 +1,10 @@
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
const high = i >>> 4
const low = i & 0x0f
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
})
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/

View File

@@ -0,0 +1,31 @@
import { HASHLINE_DICT } from "./constants.js"
import { hashXxh32 } from "./xxhash32.js"
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
const stripped = normalizedContent
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
const hash = hashXxh32(stripped, seed)
const index = hash % 256
return HASHLINE_DICT[index]!
}
export function computeLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd())
}
export function computeLegacyLineHash(lineNumber: number, content: string): string {
return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").replace(/\s+/g, ""))
}
export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}#${hash}|${content}`
}
export function formatHashLines(content: string): string {
if (!content) return ""
const lines = content.split("\n")
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
}

View File

@@ -0,0 +1,11 @@
/**
* Hashline editing core — content-hash anchors for edit_file stale-patch detection.
*
* Ported from oh-my-openagent/packages/hashline-core/.
* Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback).
*/
export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js"
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js"
export type { LineRef } from "./validation.js"
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js"
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js"

View File

@@ -0,0 +1,20 @@
export interface ReplaceEdit {
op: "replace"
pos: string
end?: string
lines: string | string[]
}
export interface AppendEdit {
op: "append"
pos?: string
lines: string | string[]
}
export interface PrependEdit {
op: "prepend"
pos?: string
lines: string | string[]
}
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit

View File

@@ -0,0 +1,192 @@
import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js"
import { HASHLINE_REF_PATTERN } from "./constants.js"
export interface LineRef {
line: number
hash: string
}
interface HashMismatch {
line: number
expected: string
}
const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
function isCompatibleLineHash(line: number, content: string, hash: string): boolean {
return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash
}
export function normalizeLineRef(ref: string): string {
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.trim()
if (HASHLINE_REF_PATTERN.test(trimmed)) {
return trimmed
}
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
if (extracted) {
return extracted[1]!
}
return originalTrimmed
}
export function parseLineRef(ref: string): LineRef {
const normalized = normalizeLineRef(ref)
const match = normalized.match(HASHLINE_REF_PATTERN)
if (match) {
return {
line: Number.parseInt(match[1]!, 10),
hash: match[2]!,
}
}
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
const suffix = normalized.slice(hashIdx + 1)
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
throw new Error(
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
`Use the actual line number from the read output.`
)
}
}
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
)
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
const content = lines[line - 1]
if (content === undefined) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
if (!isCompatibleLineHash(line, content, hash)) {
throw new HashlineMismatchError([{ line, expected: hash }], lines)
}
}
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>
constructor(
private readonly mismatches: HashMismatch[],
private readonly fileLines: string[]
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
this.name = "HashlineMismatchError"
const remaps = new Map<string, string>()
for (const mismatch of mismatches) {
const content = fileLines[mismatch.line - 1]
const actualLine = content ?? ""
const actual = computeLineHash(mismatch.line, actualLine)
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
}
this.remaps = remaps
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchByLine = new Map<number, HashMismatch>()
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
const displayLines = new Set<number>()
for (const mismatch of mismatches) {
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
for (let line = low; line <= high; line++) displayLines.add(line)
}
const sortedLines = [...displayLines].sort((a, b) => a - b)
const output: string[] = []
output.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
)
output.push("")
let previousLine = -1
for (const line of sortedLines) {
if (previousLine !== -1 && line > previousLine + 1) {
output.push(" ...")
}
previousLine = line
const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`)
} else {
output.push(` ${prefix}`)
}
}
return output.join("\n")
}
}
function suggestLineForHash(ref: string, lines: string[]): string | null {
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
if (!hashMatch) return null
const hash = hashMatch[1]!
for (let i = 0; i < lines.length; i++) {
if (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) {
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?`
}
}
return null
}
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
try {
return parseLineRef(ref)
} catch (parseError) {
const hint = suggestLineForHash(ref, lines)
if (hint && parseError instanceof Error) {
throw new Error(`${parseError.message} ${hint}`)
}
throw parseError
}
}
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
const content = lines[line - 1]
if (content === undefined) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
if (!isCompatibleLineHash(line, content, hash)) {
mismatches.push({ line, expected: hash })
}
}
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, lines)
}
}

View File

@@ -0,0 +1,90 @@
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
const encoder = new TextEncoder()
const PRIME32_1 = 0x9e3779b1
const PRIME32_2 = 0x85ebca77
const PRIME32_3 = 0xc2b2ae3d
const PRIME32_4 = 0x27d4eb2f
const PRIME32_5 = 0x165667b1
function rotateLeft32(value: number, bits: number): number {
return ((value << bits) | (value >>> (32 - bits))) >>> 0
}
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
return (
((input[offset] ?? 0) |
((input[offset + 1] ?? 0) << 8) |
((input[offset + 2] ?? 0) << 16) |
((input[offset + 3] ?? 0) << 24)) >>>
0
)
}
function round32(accumulator: number, value: number): number {
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
}
function xxHash32Js(input: Uint8Array, seed: number): number {
let offset = 0
const length = input.length
let hash: number
if (length >= 16) {
const limit = length - 16
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
let value2 = (seed + PRIME32_2) >>> 0
let value3 = seed >>> 0
let value4 = (seed - PRIME32_1) >>> 0
while (offset <= limit) {
value1 = round32(value1, readUint32LittleEndian(input, offset))
offset += 4
value2 = round32(value2, readUint32LittleEndian(input, offset))
offset += 4
value3 = round32(value3, readUint32LittleEndian(input, offset))
offset += 4
value4 = round32(value4, readUint32LittleEndian(input, offset))
offset += 4
}
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
hash = (hash + rotateLeft32(value3, 12)) >>> 0
hash = (hash + rotateLeft32(value4, 18)) >>> 0
} else {
hash = (seed + PRIME32_5) >>> 0
}
hash = (hash + length) >>> 0
while (offset + 4 <= length) {
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
offset += 4
}
while (offset < length) {
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
offset += 1
}
hash = (hash ^ (hash >>> 15)) >>> 0
hash = Math.imul(hash, PRIME32_2) >>> 0
hash = (hash ^ (hash >>> 13)) >>> 0
hash = Math.imul(hash, PRIME32_3) >>> 0
return (hash ^ (hash >>> 16)) >>> 0
}
export function hashXxh32(input: string, seed: number): number {
const bun = runtime.Bun
if (bun !== undefined) {
return bun.hash.xxHash32(input, seed)
}
return xxHash32Js(encoder.encode(input), seed >>> 0)
}

View File

@@ -0,0 +1,34 @@
import type { ModelMetadata } from "./provider-cache.js"
export interface ProviderModelsCache {
readonly models: Record<string, readonly string[] | readonly ModelMetadata[]>
readonly connected: readonly string[]
readonly updatedAt: string
}
export interface ConnectedProvidersAdapter {
readConnectedProvidersCache(): string[] | null
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
readProviderModelsCache(): ProviderModelsCache | null
}
export function readConnectedProvidersCache(): string[] | null {
return null
}
export function findProviderModelMetadata(
_providerID: string,
_modelID: string,
): ModelMetadata | undefined {
return undefined
}
export function readProviderModelsCache(): ProviderModelsCache | null {
return null
}
export const connectedProvidersAdapter: ConnectedProvidersAdapter = {
readConnectedProvidersCache,
findProviderModelMetadata,
readProviderModelsCache,
}

View File

@@ -0,0 +1,128 @@
import type { FallbackEntry } from "./model-requirement-types.js"
import type { FallbackModelObject } from "./fallback-model-object.js"
import { normalizeFallbackModels } from "./model-resolver.js"
import { KNOWN_VARIANTS } from "./known-variants.js"
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
if (typeof rawModel !== "string") {
return { modelID: "" }
}
const trimmedModel = rawModel.trim()
if (!trimmedModel) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModel }
}
export function parseFallbackModelEntry(
model: string,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
if (typeof model !== "string") return undefined
const trimmed = model.trim()
if (!trimmed) return undefined
const parts = trimmed.split("/")
const providerID =
parts.length >= 2 ? (parts[0]?.trim() ?? "") : (contextProviderID?.trim() || defaultProviderID)
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
if (!providerID || !rawModelID) return undefined
const parsed = parseVariantFromModel(rawModelID)
if (!parsed.modelID) return undefined
return {
providers: [providerID],
model: parsed.modelID,
variant: parsed.variant,
}
}
export function parseFallbackModelObjectEntry(
obj: FallbackModelObject,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
const base = parseFallbackModelEntry(obj.model, contextProviderID, defaultProviderID)
if (!base) return undefined
return {
...base,
variant: obj.variant ?? base.variant,
reasoningEffort: obj.reasoningEffort,
temperature: obj.temperature,
top_p: obj.top_p,
maxTokens: obj.maxTokens,
thinking: obj.thinking,
}
}
/**
* Find the most specific FallbackEntry whose `provider/model` is a prefix of
* the resolved `provider/modelID`. Longest match wins so that e.g.
* `openai/gpt-5.4-preview` picks the entry for `openai/gpt-5.4-preview` over
* the shorter `openai/gpt-5.4`.
*/
export function findMostSpecificFallbackEntry(
providerID: string,
modelID: string,
chain: FallbackEntry[],
): FallbackEntry | undefined {
const resolved = `${providerID}/${modelID}`.toLowerCase()
// Collect entries whose provider/model is a prefix of the resolved model,
// together with the length of the matching prefix (longest match wins).
const matches: { entry: FallbackEntry; matchLen: number }[] = []
for (const entry of chain) {
for (const p of entry.providers) {
const candidate = `${p}/${entry.model}`.toLowerCase()
if (resolved.startsWith(candidate)) {
matches.push({ entry, matchLen: candidate.length })
break // one match per entry is enough
}
}
}
if (matches.length === 0) return undefined
matches.sort((a, b) => b.matchLen - a.matchLen)
return matches[0]!.entry
}
export function buildFallbackChainFromModels(
fallbackModels: string | (string | FallbackModelObject)[] | undefined,
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry[] | undefined {
const normalized = normalizeFallbackModels(fallbackModels)
if (!normalized || normalized.length === 0) return undefined
const parsed = normalized
.map((entry) => {
if (typeof entry === "string") {
return parseFallbackModelEntry(entry, contextProviderID, defaultProviderID)
}
return parseFallbackModelObjectEntry(entry, contextProviderID, defaultProviderID)
})
.filter((entry): entry is FallbackEntry => entry !== undefined)
if (parsed.length === 0) return undefined
return parsed
}

View File

@@ -0,0 +1,9 @@
export type FallbackModelObject = {
readonly model: string
readonly variant?: string
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
readonly temperature?: number
readonly top_p?: number
readonly maxTokens?: number
readonly thinking?: { readonly type: "enabled" | "disabled"; readonly budgetTokens?: number }
}

View File

@@ -0,0 +1,80 @@
export type {
FallbackEntry,
ModelRequirement,
} from "./model-requirement-types.js"
export type {
FallbackModelObject,
} from "./fallback-model-object.js"
export type {
DelegatedModelConfig,
ModelResolutionRequest,
ModelResolutionProvenance,
ModelResolutionResult,
} from "./model-resolution-types.js"
export type {
ModelResolutionInput,
ModelSource,
ExtendedModelResolutionInput,
} from "./model-resolver.js"
export {
resolveModel,
resolveModelWithFallback,
normalizeFallbackModels,
flattenToFallbackModelStrings,
} from "./model-resolver.js"
export {
normalizeModel,
normalizeModelID,
} from "./model-normalization.js"
export {
fuzzyMatchModel,
isModelAvailable,
} from "./model-availability.js"
export {
transformModelForProvider,
transformModelForProviderDisplay,
} from "./provider-model-id-transform.js"
export {
buildFallbackChainFromModels,
parseFallbackModelEntry,
parseFallbackModelObjectEntry,
findMostSpecificFallbackEntry,
} from "./fallback-chain-from-models.js"
export {
KNOWN_VARIANTS,
} from "./known-variants.js"
export {
_setModelResolutionLogImplementationForTesting,
resolveModelPipeline,
} from "./model-resolution-pipeline.js"
export type {
ModelResolutionRequest as PipelineModelResolutionRequest,
ModelResolutionProvenance as PipelineModelResolutionProvenance,
ModelResolutionResult as PipelineModelResolutionResult,
ModelResolutionDeps,
} from "./model-resolution-pipeline.js"
export {
isRetryableModelError,
shouldRetryError,
getNextFallback,
hasMoreFallbacks,
selectFallbackProvider,
selectFallbackProviderWithCache,
} from "./model-error-classifier.js"
export type {
ErrorInfo,
} from "./model-error-classifier.js"
export type {
ProviderCache,
ModelMetadata,
} from "./provider-cache.js"
export type {
ProviderModelsCache,
ConnectedProvidersAdapter,
} from "./connected-providers-cache.js"
export {
readConnectedProvidersCache,
findProviderModelMetadata,
readProviderModelsCache,
connectedProvidersAdapter,
} from "./connected-providers-cache.js"

View File

@@ -0,0 +1,16 @@
/**
* Canonical set of recognised variant / effort tokens.
* Used by parseFallbackModelEntry (space-suffix detection) and
* flattenToFallbackModelStrings (inline-variant stripping).
*/
export const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])

View File

@@ -0,0 +1,64 @@
function normalizeModelName(name: string): string {
return name
.toLowerCase()
.replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3")
}
export function fuzzyMatchModel(
target: string,
available: Set<string>,
providers?: string[],
): string | null {
if (available.size === 0) {
return null
}
const targetNormalized = normalizeModelName(target)
let candidates = Array.from(available)
if (providers && providers.length > 0) {
const providerSet = new Set(providers)
candidates = candidates.filter((model) => {
const [provider] = model.split("/")
return providerSet.has(provider!)
})
}
if (candidates.length === 0) {
return null
}
const matches = candidates.filter((model) =>
normalizeModelName(model).includes(targetNormalized),
)
if (matches.length === 0) {
return null
}
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
if (exactMatch) {
return exactMatch
}
const exactModelIdMatches = matches.filter((model) => {
const modelId = model.split("/").slice(1).join("/")
return normalizeModelName(modelId) === targetNormalized
})
if (exactModelIdMatches.length > 0) {
return exactModelIdMatches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
}
return matches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
}
export function isModelAvailable(
targetModel: string,
availableModels: Set<string>,
): boolean {
return fuzzyMatchModel(targetModel, availableModels) !== null
}

View File

@@ -0,0 +1,261 @@
import type { FallbackEntry } from "./model-requirement-types.js"
import type { ProviderCache } from "./provider-cache.js"
import * as connectedProvidersCache from "./connected-providers-cache.js"
/**
* Error names that indicate a retryable model error.
* These errors halt execution and should trigger fallback retry.
*/
const RETRYABLE_ERROR_NAMES = new Set([
"providermodelnotfounderror",
"ratelimiterror",
"modelunavailableerror",
"providerconnectionerror",
"authenticationerror",
])
const STOP_ERROR_NAMES = new Set([
"quotaexceedederror",
"insufficientcreditserror",
"freeusagelimiterror",
])
/**
* Error names that should NOT trigger retry.
* These errors are typically user-induced or fixable without switching models.
*/
const NON_RETRYABLE_ERROR_NAMES = new Set([
"messageabortederror",
"permissiondeniederror",
"contextlengtherror",
"timeouterror",
"validationerror",
"syntaxerror",
"usererror",
])
/**
* Message patterns that indicate a retryable error even without a known error name.
*/
const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit",
"rate limit",
"usage_limit_reached",
"usage limit has been reached",
"quota",
"all credentials for model",
"cooling down",
"exhausted your capacity",
"not found",
"unavailable",
"insufficient",
"too many requests",
"over limit",
"overloaded",
"bad gateway",
"bad request",
"unknown provider",
"provider not found",
"model_not_supported",
"model not supported",
"model is not supported",
"connection error",
"network error",
"timeout",
"service unavailable",
"internal_server_error",
"free usage",
"usage exceeded",
"credit",
"balance",
"temporarily unavailable",
"try again",
"请稍后重试",
"503",
"502",
"504",
"429",
"529",
"selected provider is forbidden",
"provider is forbidden",
// Chinese retryable patterns (Zhipu, etc.)
"频率限制", // "rate limit"
"请求过于频繁", // "too many requests"
"暂时不可用", // "temporarily unavailable"
"服务不可用", // "service unavailable"
"server_error",
"an error occurred while processing",
]
/**
* Message patterns that indicate a non-retryable STOP error (quota/billing exhaustion).
* These take precedence over RETRYABLE_MESSAGE_PATTERNS.
*/
const STOP_MESSAGE_PATTERNS = [
"quota will reset after",
"quota exceeded",
"free usage limit",
"billing limit",
"billing hard limit",
"monthly limit",
"plan limit",
"subscription quota",
"subscription limit",
"payment required",
"out of credits",
"credits exhausted",
"insufficient credits",
"insufficient balance",
"credit balance",
"usage limit for this month",
"exhausted your capacity",
// GLM/Z.ai business error codes that indicate permanent quota/billing exhaustion
"daily call limit",
"daily limit",
"usage limit reached for",
"in arrears",
"fair use policy",
"recharge and try",
"使用上限",
"额度不足",
"余额不足",
"已耗尽",
]
const AUTO_RETRY_GATE_PATTERNS = [
"rate limit",
"cooling down",
"credentials for model",
]
function hasProviderAutoRetrySignal(message: string): boolean {
if (!message.includes("retrying in")) {
return false
}
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
}
export interface ErrorInfo {
name?: string
message?: string
/** HTTP status code from the provider response (e.g., 429 for rate limit) */
statusCode?: number
}
/**
* Determines if an error is a retryable model error.
* Returns true if it's a known retryable type OR matches retryable message patterns.
*/
export function isRetryableModelError(error: ErrorInfo): boolean {
// If we have an error name, check against known lists
if (error.name) {
const errorNameLower = error.name.toLowerCase()
// Explicit non-retryable takes precedence
if (NON_RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
return false
}
if (STOP_ERROR_NAMES.has(errorNameLower)) {
return false
}
// Check if it's a known retryable error
if (RETRYABLE_ERROR_NAMES.has(errorNameLower)) {
return true
}
}
// Check message patterns for unknown errors
const msg = error.message?.toLowerCase() ?? ""
// STOP patterns take precedence over retryable patterns
if (STOP_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))) {
return false
}
if (hasProviderAutoRetrySignal(msg)) {
return true
}
// HTTP status code check: catches rate-limit errors regardless of message format/language.
// Uses the same codes as runtime-fallback config (400 excluded as it is a permanent client error).
if (
error.statusCode != null &&
(error.statusCode === 429 || error.statusCode === 503 || error.statusCode === 529)
) {
return true
}
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
}
/**
* Determines if an error should trigger a fallback retry.
* Returns true for errors that halt execution.
*/
export function shouldRetryError(error: ErrorInfo): boolean {
return isRetryableModelError(error)
}
/**
* Gets the next fallback model from the chain based on attempt count.
* Returns undefined if all fallbacks have been exhausted.
*/
export function getNextFallback(
fallbackChain: FallbackEntry[],
attemptCount: number,
): FallbackEntry | undefined {
return fallbackChain[attemptCount]
}
/**
* Checks if there are more fallbacks available after the current attempt.
*/
export function hasMoreFallbacks(
fallbackChain: FallbackEntry[],
attemptCount: number,
): boolean {
return attemptCount < fallbackChain.length
}
/**
* Selects the best provider for a fallback entry.
* Priority:
* 1) First connected provider in the entry's provider preference order
* 2) Preferred provider when connected (and entry providers are unavailable)
* 3) First provider listed in the fallback entry
*/
export function selectFallbackProvider(
providers: string[],
preferredProviderID?: string,
): string {
return selectFallbackProviderWithCache(
providers,
connectedProvidersCache,
preferredProviderID,
)
}
export function selectFallbackProviderWithCache(
providers: string[],
providerCache: ProviderCache,
preferredProviderID?: string,
): string {
const connectedProviders = providerCache.readConnectedProvidersCache()
if (connectedProviders) {
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
for (const provider of providers) {
if (connectedSet.has(provider.toLowerCase())) {
return provider
}
}
if (
preferredProviderID &&
connectedSet.has(preferredProviderID.toLowerCase())
) {
return preferredProviderID
}
}
return providers[0] ?? preferredProviderID ?? "opencode"
}

View File

@@ -0,0 +1,8 @@
export function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim()
return trimmed || undefined
}
export function normalizeModelID(modelID: string): string {
return modelID.replace(/\.(\d+)/g, "-$1")
}

View File

@@ -0,0 +1,18 @@
export type FallbackEntry = {
providers: string[];
model: string;
variant?: string; // Entry-specific variant (e.g., GPT->high, Opus->max)
reasoningEffort?: string;
temperature?: number;
top_p?: number;
maxTokens?: number;
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number };
};
export type ModelRequirement = {
fallbackChain: FallbackEntry[];
variant?: string; // Default variant (used when entry doesn't specify one)
requiresModel?: string; // If set, only activates when this model is available (fuzzy match)
requiresAnyModel?: boolean; // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
requiresProvider?: string[]; // If set, only activates when any of these providers is connected
};

View File

@@ -0,0 +1,256 @@
import { fuzzyMatchModel } from "./model-availability.js"
import type { FallbackEntry } from "./model-requirement-types.js"
import { transformModelForProvider } from "./provider-model-id-transform.js"
import { normalizeModel } from "./model-normalization.js"
import type { ProviderCache } from "./provider-cache.js"
type LogImplementation = (message: string, data?: unknown) => void
let logImplementationForTesting: LogImplementation | undefined
function log(message: string, data?: unknown): void {
const logImpl = logImplementationForTesting
if (!logImpl) {
return
}
if (arguments.length === 1) {
logImpl(message)
return
}
logImpl(message, data)
}
export function _setModelResolutionLogImplementationForTesting(
logImplementation: LogImplementation | undefined,
): void {
logImplementationForTesting = logImplementation
}
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
connectedProviders?: string[] | null
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}
export type ModelResolutionDeps = {
fuzzyMatchModel: (
target: string,
available: Set<string>,
providers?: string[],
) => string | null
transformModelForProvider: (provider: string, model: string) => string
}
const DEFAULT_MODEL_RESOLUTION_DEPS: ModelResolutionDeps = {
fuzzyMatchModel,
transformModelForProvider,
}
export function resolveModelPipeline(
request: ModelResolutionRequest,
providerCache: ProviderCache = {
readConnectedProvidersCache: () => null,
findProviderModelMetadata: () => undefined,
},
deps: ModelResolutionDeps = DEFAULT_MODEL_RESOLUTION_DEPS,
): ModelResolutionResult | undefined {
const attempted: string[] = []
const { intent, constraints, policy } = request
const availableModels = constraints.availableModels
const fallbackChain = policy?.fallbackChain
const systemDefaultModel = policy?.systemDefaultModel
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
if (normalizedUiModel) {
log("Model resolved via UI selection", { model: normalizedUiModel })
return { model: normalizedUiModel, provenance: "override" }
}
const normalizedUserModel = normalizeModel(intent?.userModel)
if (normalizedUserModel) {
log("Model resolved via config override", { model: normalizedUserModel })
return { model: normalizedUserModel, provenance: "override" }
}
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
if (normalizedCategoryDefault) {
attempted.push(normalizedCategoryDefault)
if (availableModels.size > 0) {
const parts = normalizedCategoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
const match = deps.fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
if (match) {
log("Model resolved via category default (fuzzy matched)", {
original: normalizedCategoryDefault,
matched: match,
})
return { model: match, provenance: "category-default", attempted }
}
} else {
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
if (connectedProviders === null) {
log("Model resolved via category default (no cache, first run)", {
model: normalizedCategoryDefault,
})
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
}
const parts = normalizedCategoryDefault.split("/")
if (parts.length >= 2) {
const provider = parts[0]!
if (connectedProviders.includes(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
log("Model resolved via category default (connected provider)", {
model: transformedModel,
original: normalizedCategoryDefault,
})
return { model: transformedModel, provenance: "category-default", attempted }
}
}
}
log("Category default model not available, falling through to fallback chain", {
model: normalizedCategoryDefault,
})
}
//#when - user configured fallback_models, try them before hardcoded fallback chain
const userFallbackModels = intent?.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet !== null) {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
if (parts.length >= 2) {
const provider = parts[0]!
if (connectedSet.has(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
return { model: transformedModel, provenance: "provider-fallback", attempted }
}
}
}
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
}
} else {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
const providerHint = parts.length >= 2 ? [parts[0]!] : undefined
const match = deps.fuzzyMatchModel(model, availableModels, providerHint)
if (match) {
log("Model resolved via user fallback_models (availability confirmed)", { model, match })
return { model: match, provenance: "provider-fallback", attempted }
}
}
log("No available model found in user fallback_models, falling through to hardcoded chain")
}
}
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? providerCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet === null) {
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const transformedModelId = deps.transformModelForProvider(provider, entry.model)
const model = `${provider}/${transformedModelId}`
log("Model resolved via fallback chain (connected provider)", {
provider,
model: transformedModelId,
variant: entry.variant,
})
return {
model,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
}
log("No connected provider found in fallback chain, falling through to system default")
}
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = deps.fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", {
provider,
model: entry.model,
match,
variant: entry.variant,
})
return {
model: match,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
const crossProviderMatch = deps.fuzzyMatchModel(entry.model, availableModels)
if (crossProviderMatch) {
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: entry.model,
match: crossProviderMatch,
variant: entry.variant,
})
return {
model: crossProviderMatch,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
log("No available model found in fallback chain, falling through to system default")
}
}
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, provenance: "system-default", attempted }
}

View File

@@ -0,0 +1,41 @@
import type { FallbackEntry } from "./model-requirement-types.js"
export interface DelegatedModelConfig {
providerID: string
modelID: string
variant?: string
reasoningEffort?: string
temperature?: number
top_p?: number
maxTokens?: number
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
}
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}

View File

@@ -0,0 +1,109 @@
import type { FallbackEntry } from "./model-requirement-types.js"
import type { FallbackModelObject } from "./fallback-model-object.js"
import { normalizeModel } from "./model-normalization.js"
import { resolveModelPipeline } from "./model-resolution-pipeline.js"
import { KNOWN_VARIANTS } from "./known-variants.js"
import type { ConnectedProvidersAdapter } from "./connected-providers-cache.js"
import * as connectedProvidersCache from "./connected-providers-cache.js"
export type ModelResolutionInput = {
userModel?: string
inheritedModel?: string
systemDefault?: string
}
export type ModelSource =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
source: ModelSource
variant?: string
}
export type ExtendedModelResolutionInput = {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel?: string
}
export function resolveModel(input: ModelResolutionInput): string | undefined {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
)
}
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
connectedProvidersAdapter: ConnectedProvidersAdapter = connectedProvidersCache,
): ModelResolutionResult | undefined {
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const resolved = resolveModelPipeline({
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
constraints: { availableModels },
policy: { fallbackChain, systemDefaultModel },
}, connectedProvidersAdapter)
if (!resolved) {
return undefined
}
return {
model: resolved.model,
source: resolved.provenance,
variant: resolved.variant,
}
}
/**
* Normalizes fallback_models config to a mixed array.
* Accepts string, string[], or mixed arrays of strings and FallbackModelObject entries.
*/
export function normalizeFallbackModels(
models: string | (string | FallbackModelObject)[] | undefined,
): (string | FallbackModelObject)[] | undefined {
if (!models) return undefined
if (typeof models === "string") return [models]
return models
}
/**
* Extracts plain model strings from a mixed fallback models array.
* Object entries are flattened to "model" or "model(variant)" strings.
* Use this when consumers need string[] (e.g., resolveModelForDelegateTask).
*/
export function flattenToFallbackModelStrings(
models: (string | FallbackModelObject)[] | undefined,
): string[] | undefined {
if (!models) return undefined
return models.map((entry) => {
if (typeof entry === "string") return entry
const variant = entry.variant
if (variant) {
// Strip any supported inline variant syntax before appending explicit override.
// Supports both parenthesized and space-suffix forms so we don't emit
// invalid strings like "provider/model high(low)".
const model = entry.model
.replace(/\([^()]+\)\s*$/, "")
.replace(/\s+([a-z][a-z0-9_-]*)\s*$/i, (_match: string, suffix: string) => {
const normalized = String(suffix).toLowerCase()
return KNOWN_VARIANTS.has(normalized)
? ""
: _match
})
.trim()
return `${model}(${variant})`
}
return entry.model
})
}

View File

@@ -0,0 +1,27 @@
export interface ModelMetadata {
readonly id: string
readonly provider?: string
readonly context?: number
readonly output?: number
readonly name?: string
readonly variants?: Record<string, unknown>
readonly limit?: {
readonly context?: number
readonly input?: number
readonly output?: number
}
readonly modalities?: {
readonly input?: string[]
readonly output?: string[]
}
readonly capabilities?: Record<string, unknown>
readonly reasoning?: boolean
readonly temperature?: boolean
readonly tool_call?: boolean
readonly [key: string]: unknown
}
export interface ProviderCache {
readConnectedProvidersCache(): string[] | null
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
}

View File

@@ -0,0 +1,69 @@
function inferSubProvider(model: string): string | undefined {
if (model.startsWith("claude-")) return "anthropic"
if (model.startsWith("gpt-")) return "openai"
if (model.startsWith("gemini-")) return "google"
if (model.startsWith("grok-")) return "xai"
if (model.startsWith("minimax-")) return "minimax"
if (model.startsWith("kimi-")) return "moonshotai"
if (model.startsWith("glm-")) return "zai"
return undefined
}
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
function claudeVersionDot(model: string): string {
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
}
function applyGatewayTransforms(model: string): string {
return claudeVersionDot(model).replace(
GEMINI_31_PRO_PREVIEW,
"gemini-3.1-pro-preview",
)
}
function transformModelForProviderUsingAnthropicBehavior(
provider: string,
model: string,
): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
const subProvider = model.substring(0, slashIndex)
const subModel = model.substring(slashIndex + 1)
return `${subProvider}/${applyGatewayTransforms(subModel)}`
}
const subProvider = inferSubProvider(model)
if (subProvider) {
return `${subProvider}/${applyGatewayTransforms(model)}`
}
return model
}
if (provider === "github-copilot") {
return claudeVersionDot(model)
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
return model
}
return model
}
export function transformModelForProvider(provider: string, model: string): string {
return transformModelForProviderUsingAnthropicBehavior(provider, model)
}
export function transformModelForProviderDisplay(
provider: string,
model: string,
): string {
return transformModelForProviderUsingAnthropicBehavior(provider, model)
}

View File

@@ -17,7 +17,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
description: Reviews code for bugs, security issues, and maintainability. Read-only.
---
You review code. Find real problems, not style nits.
@@ -56,7 +56,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
description: Diagnoses bugs from error messages, logs, or described symptoms.
---
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
@@ -82,7 +82,7 @@ top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 5
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
---
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
@@ -125,7 +125,7 @@ top_k: 20
min_p: 0.0
presence_penalty: 1.5
steps: 20
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes, web_fetch, web_search]
description: Designs new features, modules, or architectural changes. Outputs a build plan.
---
You design. You produce build plans, not code.
@@ -167,7 +167,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
description: Audits code for security vulnerabilities. Read-only.
---
You audit for security issues. Concrete findings only, no generic warnings.
@@ -212,7 +212,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [view_file, list_dir, grep, find_files]
tools: [find_files, get_codebase_overview, grep, list_dir, view_file]
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
---
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
@@ -250,7 +250,7 @@ top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
---
You map codebases. Start broad, then drill into specifics.
@@ -278,7 +278,7 @@ top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 10
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, watch_changes]
description: Produces actionable step plans from requirements. Read-only — never modifies files.
---
You produce actionable step plans. You do not modify files.