Compare commits
3 Commits
v2.8.11-ag
...
v2.8.14-do
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c5b2c2bcf | |||
| 524a0deaa1 | |||
| a7a40c5b46 |
204
apps/coder/src/services/behavioral/generation.ts
Normal file
204
apps/coder/src/services/behavioral/generation.ts
Normal 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;
|
||||||
|
}
|
||||||
77
apps/coder/src/services/behavioral/index.ts
Normal file
77
apps/coder/src/services/behavioral/index.ts
Normal 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';
|
||||||
435
apps/coder/src/services/behavioral/matching.ts
Normal file
435
apps/coder/src/services/behavioral/matching.ts
Normal 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 };
|
||||||
|
}
|
||||||
355
apps/coder/src/services/behavioral/resolver.ts
Normal file
355
apps/coder/src/services/behavioral/resolver.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type TerminalMessageStatus,
|
type TerminalMessageStatus,
|
||||||
} from './finalize-message.js';
|
} from './finalize-message.js';
|
||||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||||
|
import { emitHook } from '../plugins/host.js';
|
||||||
|
|
||||||
interface InferenceRunner {
|
interface InferenceRunner {
|
||||||
enqueue: (
|
enqueue: (
|
||||||
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
|
|||||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
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
|
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
||||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||||
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
// (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.
|
// Declared before try so the catch block can write it back on the task row.
|
||||||
let chatId: string | null = null;
|
let chatId: string | null = null;
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mark running
|
// Mark running
|
||||||
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
|
|||||||
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
// 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.
|
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
||||||
const model = task.model ?? config.DEFAULT_MODEL;
|
const model = task.model ?? config.DEFAULT_MODEL;
|
||||||
let sessionId: string;
|
|
||||||
if (task.session_id) {
|
if (task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
} else {
|
} else {
|
||||||
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
|
||||||
} else {
|
} else {
|
||||||
const [msg] = await sql<{ content: string | null }[]>`
|
const [msg] = await sql<{ content: string | null }[]>`
|
||||||
SELECT content FROM messages WHERE id = ${assistantId}
|
SELECT content FROM messages WHERE id = ${assistantId}
|
||||||
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(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}
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`.catch(() => {});
|
`.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 finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return;
|
return;
|
||||||
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||||
// #10: external-agent turn completed cleanly.
|
// #10: external-agent turn completed cleanly.
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
// preceded its assignment — guard so the status publish never masks the real
|
// preceded its assignment — guard so the status publish never masks the real
|
||||||
// error.
|
// error.
|
||||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
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
|
// Best-effort cleanup
|
||||||
await cleanupWorktree(projectPath, taskId);
|
await cleanupWorktree(projectPath, taskId);
|
||||||
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return; // worktree persists (no cleanup); backend stays warm
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
result.ok ? 'idle' : 'error',
|
result.ok ? 'idle' : 'error',
|
||||||
result.ok ? 'turn_complete' : 'failed',
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
);
|
);
|
||||||
|
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(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);
|
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||||
// #10: turn crashed.
|
// #10: turn crashed.
|
||||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : '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);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// 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 finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return; // worktree persists (no cleanup); backend stays warm
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
}
|
}
|
||||||
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
result.ok ? 'idle' : 'error',
|
result.ok ? 'idle' : 'error',
|
||||||
result.ok ? 'turn_complete' : 'failed',
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
);
|
);
|
||||||
|
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(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);
|
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||||
// #10: turn crashed.
|
// #10: turn crashed.
|
||||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||||
|
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// 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 finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||||
|
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
return; // worktree persists (no cleanup); backend stays warm
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
result.ok ? 'idle' : 'error',
|
result.ok ? 'idle' : 'error',
|
||||||
result.ok ? 'turn_complete' : 'failed',
|
result.ok ? 'turn_complete' : 'failed',
|
||||||
);
|
);
|
||||||
|
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(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);
|
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||||
// #10: turn crashed.
|
// #10: turn crashed.
|
||||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||||
|
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||||
clearTaskCommands(taskId);
|
clearTaskCommands(taskId);
|
||||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||||
import type { AgentEvent } from './agent-backend.js';
|
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 { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||||
import type { DcpStreamStripper } from './dcp-strip.js';
|
import type { DcpStreamStripper } from './dcp-strip.js';
|
||||||
|
import { emitHook } from '../plugins/host.js';
|
||||||
|
|
||||||
export interface FrameEmitterOpts {
|
export interface FrameEmitterOpts {
|
||||||
broker?: Broker;
|
broker?: Broker;
|
||||||
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'tool_call':
|
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':
|
case 'tool_update':
|
||||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
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()) {
|
if (canStream()) {
|
||||||
broker!.publishFrame(sessionId!, {
|
broker!.publishFrame(sessionId!, {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
|
|||||||
10
apps/coder/src/services/hashline/constants.ts
Normal file
10
apps/coder/src/services/hashline/constants.ts
Normal 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})\|(.*)$/
|
||||||
31
apps/coder/src/services/hashline/hash-computation.ts
Normal file
31
apps/coder/src/services/hashline/hash-computation.ts
Normal 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")
|
||||||
|
}
|
||||||
11
apps/coder/src/services/hashline/index.ts
Normal file
11
apps/coder/src/services/hashline/index.ts
Normal 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"
|
||||||
20
apps/coder/src/services/hashline/types.ts
Normal file
20
apps/coder/src/services/hashline/types.ts
Normal 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
|
||||||
192
apps/coder/src/services/hashline/validation.ts
Normal file
192
apps/coder/src/services/hashline/validation.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/coder/src/services/hashline/xxhash32.ts
Normal file
90
apps/coder/src/services/hashline/xxhash32.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
80
apps/coder/src/services/model-resolution/index.ts
Normal file
80
apps/coder/src/services/model-resolution/index.ts
Normal 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"
|
||||||
16
apps/coder/src/services/model-resolution/known-variants.ts
Normal file
16
apps/coder/src/services/model-resolution/known-variants.ts
Normal 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",
|
||||||
|
])
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
109
apps/coder/src/services/model-resolution/model-resolver.ts
Normal file
109
apps/coder/src/services/model-resolution/model-resolver.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
27
apps/coder/src/services/model-resolution/provider-cache.ts
Normal file
27
apps/coder/src/services/model-resolution/provider-cache.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
110
apps/server/src/services/boocontext_client.ts
Normal file
110
apps/server/src/services/boocontext_client.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* v2.7.18: shared MCP client wrapper for the boocontext sidecar.
|
||||||
|
*
|
||||||
|
* Calls into the existing multi-server MCP client infrastructure
|
||||||
|
* (services/mcp-client.ts) which connects to boocontext as a stdio
|
||||||
|
* MCP process defined in data/mcp.json (server name "boocontext",
|
||||||
|
* command: `node /opt/forks/boocontext/dist/standalone.js`).
|
||||||
|
*
|
||||||
|
* The boocontext MCP server is initialized once at app boot in
|
||||||
|
* index.ts via initMcp() and the actual MCP tool call routing is
|
||||||
|
* handled by mcp-client.ts:callTool() — this module is a thin
|
||||||
|
* convenience wrapper that prepends the "boocontext_" server prefix,
|
||||||
|
* normalises the response, and applies inline truncation matching
|
||||||
|
* the same pattern as codecontext_client.ts.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { callBoocontext } from './services/boocontext_client.js';
|
||||||
|
* const resp = await callBoocontext({
|
||||||
|
* toolName: 'codesight_get_summary',
|
||||||
|
* args: { directory: '/opt/boocode' },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callTool } from './mcp-client.js';
|
||||||
|
import { truncateIfNeeded } from './truncate.js';
|
||||||
|
|
||||||
|
// ---- Exported types ----
|
||||||
|
|
||||||
|
export interface BoocontextRequest {
|
||||||
|
/** Unprefixed tool name as defined on the boocontext MCP server
|
||||||
|
* (e.g. "codesight_scan", "boocontext_overview", "codesight_get_summary"). */
|
||||||
|
toolName: string;
|
||||||
|
/** Arguments to pass to the tool. */
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoocontextResponse {
|
||||||
|
/** The tool output text. */
|
||||||
|
result: string;
|
||||||
|
/** Whether the result was truncated to fit the inline limit. */
|
||||||
|
truncated: boolean;
|
||||||
|
/** Opaque id pointing at the full pre-slice content on tmpfs, set when
|
||||||
|
* truncated=true and storage succeeded. */
|
||||||
|
outputPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Constants ----
|
||||||
|
|
||||||
|
/** Must match the server name in data/mcp.json. */
|
||||||
|
const BOOCONTEXT_SERVER_NAME = 'boocontext';
|
||||||
|
|
||||||
|
/** Inline truncation limit, matching codecontext_client.ts. */
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a boocontext MCP tool by its unprefixed name.
|
||||||
|
*
|
||||||
|
* Prepends the "boocontext_" server prefix, delegates to the
|
||||||
|
* multi-server MCP client's callTool(), and normalises the response
|
||||||
|
* into a BoocontextResponse with inline truncation.
|
||||||
|
*
|
||||||
|
* @param req The tool name and arguments.
|
||||||
|
* @param log Optional Fastify-compatible logger (for debug traces).
|
||||||
|
* @returns The tool result, possibly truncated.
|
||||||
|
* @throws If the boocontext server is not connected or the tool
|
||||||
|
* returns an MCP-level error.
|
||||||
|
*/
|
||||||
|
export async function callBoocontext(
|
||||||
|
req: BoocontextRequest,
|
||||||
|
log?: { debug?: (obj: object, msg: string) => void; warn?: (obj: object, msg: string) => void },
|
||||||
|
): Promise<BoocontextResponse> {
|
||||||
|
const prefixedName = `${BOOCONTEXT_SERVER_NAME}_${req.toolName}`;
|
||||||
|
|
||||||
|
log?.debug?.({ tool: prefixedName }, 'boocontext: calling tool');
|
||||||
|
|
||||||
|
const raw = await callTool(prefixedName, req.args);
|
||||||
|
|
||||||
|
// callTool returns { error: true, output: string } on failure (both
|
||||||
|
// for MCP-level isError and for network/protocol exceptions).
|
||||||
|
if (typeof raw === 'object' && raw !== null && (raw as Record<string, unknown>).error === true) {
|
||||||
|
const errOutput = (raw as Record<string, unknown>).output ?? 'Unknown MCP error';
|
||||||
|
throw new Error(`boocontext error: ${String(errOutput)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||||
|
|
||||||
|
// Inline truncation at 32 kB, matching codecontext_client.ts.
|
||||||
|
// The model gets a clear hint about how to narrow the next call
|
||||||
|
// rather than a silent cut.
|
||||||
|
if (result.length > TRUNCATION_LIMIT) {
|
||||||
|
const truncated = result.slice(0, TRUNCATION_LIMIT);
|
||||||
|
const omitted = result.length - TRUNCATION_LIMIT;
|
||||||
|
const slicedWithMarker =
|
||||||
|
`${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with additional filters]`;
|
||||||
|
const wrapped = await truncateIfNeeded({
|
||||||
|
fullContent: result,
|
||||||
|
slicedContent: slicedWithMarker,
|
||||||
|
wasTruncated: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
result: wrapped.content,
|
||||||
|
truncated: wrapped.truncated,
|
||||||
|
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, truncated: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import { callBoocontext } from '../../boocontext_client.js';
|
||||||
|
|
||||||
|
export const GetCodeHealthInput = z.object({
|
||||||
|
directory: z.string().optional().describe('Directory to analyze (defaults to project root)'),
|
||||||
|
file: z.string().optional().describe('Optional: specific file to analyze'),
|
||||||
|
});
|
||||||
|
export type GetCodeHealthInputT = z.infer<typeof GetCodeHealthInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Code health analysis. Returns A–F grades per file across 7 dimensions ' +
|
||||||
|
'(cohesion, coupling, complexity, documentation, duplication, unit size, test coverage). ' +
|
||||||
|
'Includes project health summary and refactoring candidates.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function — calls the boocontext MCP server's
|
||||||
|
* boocontext_health tool and returns the raw report text.
|
||||||
|
*
|
||||||
|
* Structured for direct test access: accepts input + projectPath,
|
||||||
|
* no side effects beyond the MCP call.
|
||||||
|
*/
|
||||||
|
export async function executeGetCodeHealth(
|
||||||
|
input: GetCodeHealthInputT,
|
||||||
|
projectPath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
if (input.directory) args['directory'] = input.directory;
|
||||||
|
if (input.file) args['file'] = input.file;
|
||||||
|
const resp = await callBoocontext({ toolName: 'boocontext_health', args });
|
||||||
|
return resp.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeHealth: ToolDef<GetCodeHealthInputT> = {
|
||||||
|
name: 'get_code_health',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetCodeHealthInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_code_health',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory to analyze (defaults to project root)',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional: specific file to analyze',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return executeGetCodeHealth(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
228
apps/server/src/services/tools/codecontext/get_code_impact.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
// ======================= MCP Client =======================
|
||||||
|
|
||||||
|
const BOOCONTEXT_PATH = resolve('/opt/forks/boocontext/dist/standalone.js');
|
||||||
|
const TOOL_CALL_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
interface JsonRpcMessage {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id?: number | string;
|
||||||
|
result?: {
|
||||||
|
content?: Array<{ type: string; text: string }>;
|
||||||
|
};
|
||||||
|
error?: { code?: number; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-shot MCP JSON-RPC client for boocontext.
|
||||||
|
* Spawns the process, sends initialize + tools/call over NDJSON, returns the
|
||||||
|
* text result from the content array. The boocontext MCP server auto-detects
|
||||||
|
* newline-delimited JSON transport when the first input lacks Content-Length
|
||||||
|
* headers, which is exactly what we send.
|
||||||
|
*/
|
||||||
|
async function callBoocontext(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise<string>((resolvePromise, reject) => {
|
||||||
|
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
timeout: TOOL_CALL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
function finalize(err?: Error, result?: string): void {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolvePromise(result!);
|
||||||
|
child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout!.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr!.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err: Error) => {
|
||||||
|
finalize(new Error(`boocontext spawn error: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
if (resolved) return;
|
||||||
|
|
||||||
|
// Parse newline-delimited JSON responses from stdout
|
||||||
|
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
|
||||||
|
let toolText: string | undefined;
|
||||||
|
let toolError: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line) as JsonRpcMessage;
|
||||||
|
if (msg.id === 2) {
|
||||||
|
if (msg.error) {
|
||||||
|
toolError = msg.error.message ?? 'boocontext tool call failed';
|
||||||
|
} else if (msg.result?.content?.[0]?.text !== undefined) {
|
||||||
|
toolText = msg.result.content[0].text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolError) {
|
||||||
|
finalize(new Error(toolError));
|
||||||
|
} else if (toolText !== undefined) {
|
||||||
|
finalize(undefined, toolText);
|
||||||
|
} else {
|
||||||
|
const errSuffix =
|
||||||
|
stderr.length > 0 ? ` stderr: ${stderr.slice(0, 500)}` : '';
|
||||||
|
finalize(
|
||||||
|
new Error(`boocontext MCP call failed (exit ${code})${errSuffix}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: initialize — establishes MCP protocol version + capabilities
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'boocode-server', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: tools/call — invoke the named boocontext tool
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: toolName, arguments: args },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
child.stdin!.end();
|
||||||
|
|
||||||
|
// Safety timeout — prevent hung processes
|
||||||
|
setTimeout(() => {
|
||||||
|
finalize(
|
||||||
|
new Error(
|
||||||
|
`boocontext call timed out after ${TOOL_CALL_TIMEOUT_MS}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, TOOL_CALL_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= Tool Definition =======================
|
||||||
|
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
export const GetCodeImpactInput = z.object({
|
||||||
|
symbol: z.string().min(1).describe('Symbol name for TSA trace_impact'),
|
||||||
|
file: z.string().optional().describe('File path for codesight blast_radius'),
|
||||||
|
directory: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Directory (defaults to project root)'),
|
||||||
|
depth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(5)
|
||||||
|
.optional()
|
||||||
|
.describe('Max blast-radius traversal depth (default 1)'),
|
||||||
|
});
|
||||||
|
export type GetCodeImpactInputT = z.infer<typeof GetCodeImpactInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Impact analysis. Merges symbol-level call trace with file-level blast radius. ' +
|
||||||
|
'Use before making changes to understand change propagation. ' +
|
||||||
|
'Single call replaces separate get_symbol_info + get_blast_radius steps.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function — calls the boocontext MCP `boocontext_impact`
|
||||||
|
* tool via a short-lived child process, then wraps the result in the standard
|
||||||
|
* CodecontextResponse shape with inline truncation at 32 KB.
|
||||||
|
*/
|
||||||
|
export async function executeGetCodeImpact(
|
||||||
|
input: GetCodeImpactInputT,
|
||||||
|
projectPath: string,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = {
|
||||||
|
symbol: input.symbol,
|
||||||
|
directory: input.directory ?? projectPath,
|
||||||
|
};
|
||||||
|
if (input.file) args['file'] = input.file;
|
||||||
|
|
||||||
|
const text = await callBoocontext('boocontext_impact', args);
|
||||||
|
|
||||||
|
// Inline truncation matching codecontext_client.ts patterns (32 KB ceiling).
|
||||||
|
if (text.length > TRUNCATION_LIMIT) {
|
||||||
|
const sliced = text.slice(0, TRUNCATION_LIMIT);
|
||||||
|
const omitted = text.length - TRUNCATION_LIMIT;
|
||||||
|
return {
|
||||||
|
result: `${sliced}\n\n[truncated, ${omitted} chars omitted; narrow with symbol or file parameters]`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: text, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeImpact: ToolDef<GetCodeImpactInputT> = {
|
||||||
|
name: 'get_code_impact',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetCodeImpactInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_code_impact',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
symbol: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Symbol name for TSA trace_impact',
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File path for codesight blast_radius',
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory (defaults to project root)',
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Max blast-radius traversal depth (default 1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['symbol'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute(input, projectRoot) {
|
||||||
|
return executeGetCodeImpact(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
192
apps/server/src/services/tools/codecontext/get_code_map.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
|
||||||
|
export const GetCodeMapInput = z.object({
|
||||||
|
directory: z.string().optional().describe('Directory to scan (defaults to project root)'),
|
||||||
|
compress: z.boolean().optional().describe('Apply DCP compression if payload exceeds threshold (default: true)'),
|
||||||
|
});
|
||||||
|
export type GetCodeMapInputT = z.infer<typeof GetCodeMapInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'DCP-compressed codebase context map. Returns filenames, sizes, import relationships in a compressed format. ' +
|
||||||
|
'Use compress=false for full detail, compress=true (default) for token-efficient overview.';
|
||||||
|
|
||||||
|
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||||
|
const TOOL_TIMEOUT_MS = 30_000;
|
||||||
|
const MAX_RESULT_BYTES = 32_768;
|
||||||
|
|
||||||
|
export interface CodeMapResponse {
|
||||||
|
result: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the boocontext MCP server over stdio JSON-RPC to invoke
|
||||||
|
* the boocontext_map tool. Spawns the standalone binary, sends
|
||||||
|
* initialize + tools/call, collects NDJSON responses, and kills
|
||||||
|
* the child process.
|
||||||
|
*/
|
||||||
|
function callBoocontextMap(args: Record<string, unknown>): Promise<CodeMapResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('node', [BOOCONTEXT_PATH], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutBuf = '';
|
||||||
|
const lines: string[] = [];
|
||||||
|
let timedOut = false;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`boocontext MCP call timed out after ${TOOL_TIMEOUT_MS}ms`));
|
||||||
|
}, TOOL_TIMEOUT_MS);
|
||||||
|
|
||||||
|
function tryParse(): void {
|
||||||
|
if (resolved || timedOut) return;
|
||||||
|
|
||||||
|
// Accumulate complete NDJSON lines
|
||||||
|
const parts = stdoutBuf.split('\n');
|
||||||
|
stdoutBuf = parts.pop()! ?? '';
|
||||||
|
for (const p of parts) {
|
||||||
|
const t = p.trim();
|
||||||
|
if (t) lines.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need at least 2 responses: initialize + tools/call
|
||||||
|
if (lines.length < 2) return;
|
||||||
|
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.kill();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const callResponse = JSON.parse(lines[1]!);
|
||||||
|
if (callResponse.error) {
|
||||||
|
reject(new Error(`MCP error: ${callResponse.error.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = callResponse.result?.content;
|
||||||
|
if (!content?.[0]?.text) {
|
||||||
|
reject(new Error('Unexpected MCP response shape — missing content[0].text'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// content[0].text is JSON-stringified VerdictEnvelope from boocontext
|
||||||
|
const envelope = JSON.parse(content[0].text as string);
|
||||||
|
const details = envelope.details;
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
if (details && typeof details === 'object' && 'data' in details) {
|
||||||
|
// DcpEnvelope shape: { compressed, originalLength, compressedLength, data }
|
||||||
|
if (details.compressed) {
|
||||||
|
// Return the full DcpEnvelope as JSON so the LLM can pass it
|
||||||
|
// transparently to a decompression step
|
||||||
|
result = JSON.stringify(details);
|
||||||
|
} else {
|
||||||
|
// Uncompressed — data is the raw output
|
||||||
|
result = details.data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = JSON.stringify(details ?? envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.byteLength(result, 'utf-8') > MAX_RESULT_BYTES;
|
||||||
|
if (truncated) {
|
||||||
|
result = result.substring(0, MAX_RESULT_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ result, truncated });
|
||||||
|
} catch (e: any) {
|
||||||
|
reject(new Error(`Failed to parse boocontext response: ${e.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout!.on('data', (chunk: Buffer) => {
|
||||||
|
if (timedOut) return;
|
||||||
|
stdoutBuf += chunk.toString('utf-8');
|
||||||
|
tryParse();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr!.on('data', (_chunk: Buffer) => {
|
||||||
|
// Captured but not surfaced — logged only on parse failure
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err: Error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(new Error(`boocontext spawn failed: ${err.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!resolved && !timedOut) {
|
||||||
|
tryParse();
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(new Error('boocontext process closed without producing a valid response'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: initialize
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: tools/call for boocontext_map
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'boocontext_map', arguments: args },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCodeMap: ToolDef<GetCodeMapInputT> = {
|
||||||
|
name: 'get_code_map',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetCodeMapInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_code_map',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
directory: { type: 'string', description: 'Directory to scan (defaults to project root)' },
|
||||||
|
compress: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Apply DCP compression if payload exceeds threshold (default: true)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot): Promise<CodeMapResponse> {
|
||||||
|
return callBoocontextMap({
|
||||||
|
directory: input.directory ?? projectRoot,
|
||||||
|
compress: input.compress ?? true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeGetCodeMap(
|
||||||
|
input: GetCodeMapInputT,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<CodeMapResponse> {
|
||||||
|
return callBoocontextMap({
|
||||||
|
directory: input.directory ?? projectRoot,
|
||||||
|
compress: input.compress ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
262
apps/server/src/services/tools/codecontext/get_type_info.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import type { CodecontextResponse } from '../../codecontext_client.js';
|
||||||
|
|
||||||
|
const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js';
|
||||||
|
const TRUNCATION_LIMIT = 32_000;
|
||||||
|
|
||||||
|
export const GetTypeInfoInput = z.object({
|
||||||
|
file: z.string().min(1).describe('File path to resolve types in'),
|
||||||
|
symbol: z.string().optional().describe('Symbol name to resolve (supports regex)'),
|
||||||
|
directory: z.string().optional().describe('Project directory for type resolution context'),
|
||||||
|
});
|
||||||
|
export type GetTypeInfoInputT = z.infer<typeof GetTypeInfoInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'TypeScript type recovery. Returns type signatures, interface definitions, ' +
|
||||||
|
'generic constraints, and JSDoc for symbols in a file. Uses type-inject MCP server.';
|
||||||
|
|
||||||
|
// ---- JSON-RPC-over-stdio MCP caller for boocontext --------------------------
|
||||||
|
|
||||||
|
async function callBoocontext(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const child = spawn(process.execPath, [BOOCONTEXT_PATH], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderrBuf = '';
|
||||||
|
child.stderr!.on('data', (chunk: Buffer) => {
|
||||||
|
stderrBuf += chunk.toString('utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
let killed = false;
|
||||||
|
const killChild = () => {
|
||||||
|
if (killed) return;
|
||||||
|
killed = true;
|
||||||
|
child.kill();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read one complete JSON-RPC response from stdout (handles both
|
||||||
|
// Content-Length framed and newline-delimited transport).
|
||||||
|
async function readResponse(timeoutMs = 30_000): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Timeout reading boocontext response'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stdout!.removeListener('data', onData);
|
||||||
|
child.stdout!.removeListener('end', onEnd);
|
||||||
|
child.stdout!.removeListener('error', onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
buf += chunk.toString('utf-8');
|
||||||
|
|
||||||
|
const msg = tryExtractMessage(buf);
|
||||||
|
if (msg !== null) {
|
||||||
|
cleanup();
|
||||||
|
resolve(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf.length > 1_024 * 1_024) {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Boocontext response exceeded 1 MB'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
cleanup();
|
||||||
|
if (buf.trim()) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buf.trim()));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Boocontext stream ended with incomplete data'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error('Boocontext stream ended unexpectedly'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout!.on('data', onData);
|
||||||
|
child.stdout!.on('end', onEnd);
|
||||||
|
child.stdout!.on('error', onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the process to be fully spawned.
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('spawn', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1 — MCP initialize
|
||||||
|
let reqId = 0;
|
||||||
|
reqId++;
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id: reqId, method: 'initialize' }) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const initResp = await readResponse() as { error?: { message: string } };
|
||||||
|
if (initResp.error) {
|
||||||
|
throw new Error(`Boocontext init failed: ${initResp.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — tools/call
|
||||||
|
reqId++;
|
||||||
|
child.stdin!.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: reqId,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: toolName, arguments: args },
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const callResp = await readResponse() as {
|
||||||
|
error?: { message: string };
|
||||||
|
result?: { content?: Array<{ type: string; text: string }> };
|
||||||
|
};
|
||||||
|
if (callResp.error) {
|
||||||
|
throw new Error(`Boocontext tool call failed: ${callResp.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from the MCP tool result shape:
|
||||||
|
// { content: [{ type: "text", text: "…" }] }
|
||||||
|
const content = callResp.result?.content;
|
||||||
|
let text: string;
|
||||||
|
if (Array.isArray(content) && content.length > 0 && content[0]!.type === 'text') {
|
||||||
|
text = content[0]!.text;
|
||||||
|
} else {
|
||||||
|
text = JSON.stringify(callResp.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline truncation at 32 KB.
|
||||||
|
if (text.length > TRUNCATION_LIMIT) {
|
||||||
|
const omitted = text.length - TRUNCATION_LIMIT;
|
||||||
|
return {
|
||||||
|
result:
|
||||||
|
text.slice(0, TRUNCATION_LIMIT) +
|
||||||
|
`\n\n[truncated, ${omitted} chars omitted; narrow with file or symbol filter]`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: text, truncated: false };
|
||||||
|
} finally {
|
||||||
|
killChild();
|
||||||
|
// Give the process a moment to release resources.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, 2_000);
|
||||||
|
child.on('exit', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to extract one complete JSON-RPC message from the head of a
|
||||||
|
* buffer. Handles both Content-Length framed and newline-delimited
|
||||||
|
* formats. Returns `null` when more data is needed.
|
||||||
|
*/
|
||||||
|
function tryExtractMessage(buf: string): unknown | null {
|
||||||
|
// --- Content-Length framed ---
|
||||||
|
const headerEnd = buf.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd !== -1) {
|
||||||
|
const header = buf.substring(0, headerEnd);
|
||||||
|
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
||||||
|
if (lengthMatch) {
|
||||||
|
const contentLength = parseInt(lengthMatch[1]!, 10);
|
||||||
|
const bodyStart = headerEnd + 4;
|
||||||
|
if (buf.length >= bodyStart + contentLength) {
|
||||||
|
const jsonStr = buf.substring(bodyStart, bodyStart + contentLength);
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
}
|
||||||
|
return null; // need more data
|
||||||
|
}
|
||||||
|
// Has \r\n\r\n but no Content-Length — junk segment; skip and retry.
|
||||||
|
return tryExtractMessage(buf.substring(headerEnd + 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Newline-delimited ---
|
||||||
|
const nlIndex = buf.indexOf('\n');
|
||||||
|
if (nlIndex !== -1) {
|
||||||
|
const line = buf.substring(0, nlIndex).trim();
|
||||||
|
if (line && line.startsWith('{')) {
|
||||||
|
return JSON.parse(line);
|
||||||
|
}
|
||||||
|
// Non-JSON line (e.g. stderr echo), skip and continue.
|
||||||
|
return tryExtractMessage(buf.substring(nlIndex + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // need more data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ToolDef ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getTypeInfo: ToolDef<GetTypeInfoInputT> = {
|
||||||
|
name: 'get_type_info',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetTypeInfoInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_type_info',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: { type: 'string', description: 'File path to resolve types in' },
|
||||||
|
symbol: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Symbol name to resolve (supports regex)',
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Project directory for type resolution context',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['file'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = { file: input.file };
|
||||||
|
if (input.symbol) args['symbol'] = input.symbol;
|
||||||
|
return callBoocontext('boocontext_types', args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function matching the `execute` shape returned by
|
||||||
|
* `makeCodecontextTool` — useful for direct callers and tests.
|
||||||
|
*
|
||||||
|
* Note: unlike the HTTP-backed codecontext tools this does NOT accept a
|
||||||
|
* `fetcher` override because it communicates over stdio rather than HTTP.
|
||||||
|
*/
|
||||||
|
export async function executeGetTypeInfo(
|
||||||
|
input: GetTypeInfoInputT,
|
||||||
|
_projectPath?: string,
|
||||||
|
): Promise<CodecontextResponse> {
|
||||||
|
const args: Record<string, unknown> = { file: input.file };
|
||||||
|
if (input.symbol) args['symbol'] = input.symbol;
|
||||||
|
return callBoocontext('boocontext_types', args);
|
||||||
|
}
|
||||||
@@ -13,3 +13,8 @@ export { getBlastRadius } from './get_blast_radius.js';
|
|||||||
export { getHotFiles } from './get_hot_files.js';
|
export { getHotFiles } from './get_hot_files.js';
|
||||||
export { getRoutes } from './get_routes.js';
|
export { getRoutes } from './get_routes.js';
|
||||||
export { getMiddleware } from './get_middleware.js';
|
export { getMiddleware } from './get_middleware.js';
|
||||||
|
// v2.8.14-domain2-phase1: boocontext-backed tools.
|
||||||
|
export { getCodeHealth } from './get_code_health.js';
|
||||||
|
export { getCodeImpact } from './get_code_impact.js';
|
||||||
|
export { getTypeInfo } from './get_type_info.js';
|
||||||
|
export { getCodeMap } from './get_code_map.js';
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
getHotFiles,
|
getHotFiles,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
getMiddleware,
|
getMiddleware,
|
||||||
|
getCodeHealth,
|
||||||
|
getCodeImpact,
|
||||||
|
getTypeInfo,
|
||||||
|
getCodeMap,
|
||||||
} from './codecontext/index.js';
|
} from './codecontext/index.js';
|
||||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
@@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
|||||||
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||||
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||||
readTabByNumber as ToolDef<unknown>,
|
readTabByNumber as ToolDef<unknown>,
|
||||||
|
// v2.8.14-domain2-phase1: boocontext-backed tools. Backed by the boocontext
|
||||||
|
// MCP server. All read-only. Health, impact, types, map analysis.
|
||||||
|
getCodeHealth as ToolDef<unknown>,
|
||||||
|
getCodeImpact as ToolDef<unknown>,
|
||||||
|
getTypeInfo as ToolDef<unknown>,
|
||||||
|
getCodeMap as ToolDef<unknown>,
|
||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
Reference in New Issue
Block a user