Compare commits
6 Commits
v2.8.9-pty
...
v2.8.16-om
| Author | SHA1 | Date | |
|---|---|---|---|
| e0feb53437 | |||
| 3c5b2c2bcf | |||
| 524a0deaa1 | |||
| a7a40c5b46 | |||
| e5183cc71b | |||
| 9abc14ef82 |
37
.learnings/HEALS.md
Normal file
37
.learnings/HEALS.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Self-healing log
|
||||
|
||||
Verified fixes for runtime failures. Each entry documents a failure, its root cause, the applied fix, and the verification proof.
|
||||
|
||||
**Pattern-Key discipline:** before filing a new HEAL, search this file for an existing Pattern-Key. If found, increment `Recurrence-Count` and update `Last-Seen` — do not duplicate.
|
||||
|
||||
**Lifecycle:** verified heals at Recurrence-Count ≥ 3 across distinct tasks get a `Handoff` block for promotion to project memory (`CLAUDE.md`, `AGENTS.md`, or a skill).
|
||||
|
||||
---
|
||||
|
||||
## [HEAL-YYYYMMDD-XXX] short_kebab_name
|
||||
|
||||
**Logged**: ISO-8601 timestamp
|
||||
**Status**: pending-verify
|
||||
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||
**Area**: free-form tag (e.g. `build`, `tests`, `ci`, `auth`, `data-pipeline`)
|
||||
**Priority**: low | medium | high | critical
|
||||
|
||||
### Failure
|
||||
Concrete error: command, error message, exit code, blocked action.
|
||||
|
||||
### Diagnosis
|
||||
Root cause as understood after investigation. What was verified during diagnosis.
|
||||
|
||||
### Fix
|
||||
Patch applied. Verbatim commands, code snippets, or pointers to `.learnings/heals/<HEAL-ID>/`.
|
||||
|
||||
### Verification
|
||||
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **Proof.**
|
||||
|
||||
### Metadata
|
||||
- Related Files: path/to/file.ext
|
||||
- See Also: HEAL-... | LRN-... | ERR-...
|
||||
- Pattern-Key: lower.snake.case (e.g. `env.lockfile_mismatch`)
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: YYYY-MM-DD
|
||||
- Last-Seen: YYYY-MM-DD
|
||||
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,
|
||||
} from './finalize-message.js';
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (
|
||||
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
|
||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||
}
|
||||
|
||||
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
|
||||
// is silently swallowed so it never blocks the dispatch flow.
|
||||
function emitTurnEnd(
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
state: string,
|
||||
agent?: string | null,
|
||||
model?: string | null,
|
||||
outputSummary?: string,
|
||||
): void {
|
||||
void emitHook('turn.end', {
|
||||
sessionId,
|
||||
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
|
||||
});
|
||||
}
|
||||
|
||||
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
||||
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
|
||||
|
||||
// Declared before try so the catch block can write it back on the task row.
|
||||
let chatId: string | null = null;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
|
||||
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
||||
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
||||
const model = task.model ?? config.DEFAULT_MODEL;
|
||||
let sessionId: string;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
} else {
|
||||
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
return;
|
||||
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
// #10: external-agent turn completed cleanly.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
|
||||
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
|
||||
} catch (err) {
|
||||
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
|
||||
// preceded its assignment — guard so the status publish never masks the real
|
||||
// error.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
|
||||
result.ok ? 'idle' : 'error',
|
||||
result.ok ? 'turn_complete' : 'failed',
|
||||
);
|
||||
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { AgentEvent } from './agent-backend.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import type { DcpStreamStripper } from './dcp-strip.js';
|
||||
import { emitHook } from '../plugins/host.js';
|
||||
|
||||
export interface FrameEmitterOpts {
|
||||
broker?: Broker;
|
||||
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
|
||||
}
|
||||
break;
|
||||
case 'tool_call':
|
||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||
if (canStream()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantId!,
|
||||
chat_id: chatId!,
|
||||
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||
} as WsFrame);
|
||||
}
|
||||
break;
|
||||
case 'tool_update':
|
||||
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
|
||||
{
|
||||
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
|
||||
if (lifecycle === 'completed' || lifecycle === 'failed') {
|
||||
void emitHook('tool.execute.after', {
|
||||
toolName: e.toolCall.title,
|
||||
args: e.toolCall.rawInput,
|
||||
result: e.toolCall.rawOutput,
|
||||
duration: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (canStream()) {
|
||||
broker!.publishFrame(sessionId!, {
|
||||
type: 'tool_call',
|
||||
|
||||
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 { getRoutes } from './get_routes.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,
|
||||
getRoutes,
|
||||
getMiddleware,
|
||||
getCodeHealth,
|
||||
getCodeImpact,
|
||||
getTypeInfo,
|
||||
getCodeMap,
|
||||
} from './codecontext/index.js';
|
||||
// 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
|
||||
@@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
// 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.
|
||||
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));
|
||||
|
||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
@@ -17,7 +17,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||
---
|
||||
You review code. Find real problems, not style nits.
|
||||
@@ -56,7 +56,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||
---
|
||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||
@@ -82,7 +82,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 5
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||
@@ -125,7 +125,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 1.5
|
||||
steps: 20
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes, web_fetch, web_search]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
You design. You produce build plans, not code.
|
||||
@@ -167,7 +167,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output]
|
||||
description: Audits code for security vulnerabilities. Read-only.
|
||||
---
|
||||
You audit for security issues. Concrete findings only, no generic warnings.
|
||||
@@ -212,7 +212,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
tools: [find_files, get_codebase_overview, grep, list_dir, view_file]
|
||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
||||
---
|
||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
||||
@@ -250,7 +250,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, request_read_access, search_symbols, view_file, view_truncated_output, watch_changes]
|
||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||
---
|
||||
You map codebases. Start broad, then drill into specifics.
|
||||
@@ -278,7 +278,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 10
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
tools: [ask_user_input, find_files, get_blast_radius, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_hot_files, get_middleware, get_routes, get_semantic_neighborhoods, get_symbol_info, git_status, grep, list_dir, request_read_access, search_symbols, view_file, watch_changes]
|
||||
description: Produces actionable step plans from requirements. Read-only — never modifies files.
|
||||
---
|
||||
You produce actionable step plans. You do not modify files.
|
||||
|
||||
287
data/skills/boocode/self-healing/SKILL.md
Normal file
287
data/skills/boocode/self-healing/SKILL.md
Normal file
@@ -0,0 +1,287 @@
|
||||
---
|
||||
name: self-healing
|
||||
description: "Active runtime recovery for coding agents: when something breaks mid-task, diagnose the root cause, write a fix, VERIFY by re-running the broken thing, then file a `HEAL-` entry to `.learnings/HEALS.md` with proof. Use whenever a command, test, build, or lint fails or exits non-zero; on missing tooling, dependency/lockfile mismatch, wrong runtime version, venv or permission errors, port conflicts, dirty git state, or a missing `.env`; when the agent needs a helper or one-off script that doesn't exist yet; when an external API, tool, or MCP errors or rate-limits; or when a test flakes. Search `HEALS.md` by `Pattern-Key` first — most heals are recurrences, so increment `Recurrence-Count` instead of duplicating. Verify is mandatory: mark `pending-verify` honestly if sandboxed, `abandoned` if the fix can't be made to work. Pairs with `self-improvement` (which promotes recurring heals to durable memory) but owns the verify-before-persist discipline self-improvement doesn't."
|
||||
---
|
||||
|
||||
# Self-Healing
|
||||
|
||||
Active runtime recovery for coding agents. When something breaks, run the loop: **diagnose → patch → verify → file**. Leave behind a reusable, verified artifact instead of a swept-under-the-rug failure.
|
||||
|
||||
The premise mirrors [browser-use/browser-harness](https://github.com/browser-use/browser-harness): *the harness improves itself every run*. An agent that hits a gap doesn't fail — it writes the fix during execution, verifies it works, and files the durable artifact for future runs. Coding tasks deserve the same loop.
|
||||
|
||||
## What this skill is for
|
||||
|
||||
When a coding agent hits a wall mid-task, the default failure modes are:
|
||||
|
||||
1. **Paper over it** — "let me try a different approach" — and lose the recovery
|
||||
2. **Pretend the fix worked** — without re-running the broken thing
|
||||
3. **Symptom-fix** — skip the test, swallow the error, retry until green
|
||||
|
||||
All three turn a one-time failure into a recurrence. The next agent on the same project hits the same wall.
|
||||
|
||||
This skill enforces one discipline: **verify before persist**. A patch isn't real until you've re-run the failing operation and watched it succeed. When it does, file the verified fix so the next run benefits.
|
||||
|
||||
## Relationship to self-improvement
|
||||
|
||||
These two skills are deliberately split. Run both — they feed each other but don't overlap.
|
||||
|
||||
| Aspect | `self-healing` (this skill) | `self-improvement` |
|
||||
| ----------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| **When** | During execution, failure is live | After the fact, at natural breakpoints |
|
||||
| **Verb** | Heal now — restore working state | Remember for later — accumulate knowledge |
|
||||
| **Outcome** | Verified patch + (optional) reusable artifact | Logged learning, correction, request |
|
||||
| **Verify** | **Mandatory** — no persist without proof | Not required |
|
||||
| **Files** | `.learnings/HEALS.md` + `.learnings/heals/<HEAL-ID>/` (lazy) | `.learnings/ERRORS.md`, `LEARNINGS.md`, `FEATURE_REQUESTS.md` |
|
||||
| **Trigger** | Failure observed mid-task | Correction, knowledge gap, feature request, recurrence |
|
||||
|
||||
**Boundary rule:** if you're capturing a fact, a correction, or a wish — that's `self-improvement`. If you're applying and verifying a fix to a live failure — that's `self-healing`.
|
||||
|
||||
## The Heal Loop
|
||||
|
||||
```
|
||||
● failure observed
|
||||
│
|
||||
● 1. DIAGNOSE capture context — command, error, env, what was attempted
|
||||
│ search HEALS.md for the same Pattern-Key first
|
||||
│ (most heals are recurrences; don't reinvent)
|
||||
│
|
||||
● 2. PATCH write the fix — script, helper, env tweak, alt command
|
||||
│ artifacts → .learnings/heals/<HEAL-ID>/ (only if needed)
|
||||
│
|
||||
● 3. VERIFY re-run the failing op — must succeed
|
||||
│ ↻ if still failing: refine and retry, cap at 3 attempts
|
||||
│ ✗ if uncrackable: file Status: abandoned with notes
|
||||
│
|
||||
● 4. FILE write HEAL-YYYYMMDD-XXX to .learnings/HEALS.md
|
||||
│ with Pattern-Key, status, verification proof
|
||||
│
|
||||
✓ working state restored, heal persisted
|
||||
|
||||
(conditional) PROMOTE if Pattern-Key recurrence ≥ 3 across distinct tasks,
|
||||
append a Handoff block → self-improvement promotes to memory
|
||||
```
|
||||
|
||||
If you abandon a heal mid-loop, don't pretend it succeeded. File a `HEAL-` entry with `Status: abandoned` and notes on what didn't work. The next agent learns from the dead end too.
|
||||
|
||||
## When to trigger
|
||||
|
||||
Self-healing fires on **active failures during execution** — the agent has just observed something not working and needs to make it work to continue. Five shapes:
|
||||
|
||||
### 1. Tool failure (command / test / build / lint)
|
||||
Any invocation exits non-zero or produces wrong output. Don't acknowledge and retry verbatim — diagnose, patch, verify.
|
||||
|
||||
*Examples:* `npm install` errors when a `pnpm-lock.yaml` is present (switch tool); `pytest` fails with `ModuleNotFoundError` (activate the venv); `tsc` flags a stale type (regenerate the client); `eslint` reports a config error (install the missing parser).
|
||||
|
||||
### 2. Missing capability / tool gap
|
||||
The agent needs something that doesn't exist yet — a script, a helper, a wrapper, a glue function. Write it in the moment. This is the closest analog to browser-harness's `agent_helpers.py`.
|
||||
|
||||
*Examples:* dedupe a CSV by custom key (write a small Python helper); bootstrap 12 microservices the same way (write `scripts/bootstrap-all.sh`); bulk-rename branches matching a pattern (write a `gh`-based shell helper).
|
||||
|
||||
### 3. Environment issue
|
||||
The local environment isn't what the project expects. Detect, patch, verify.
|
||||
|
||||
*Examples:* runtime version mismatch (`nvm use`, `pyenv local`, `rustup override`); stale dependency cache after a branch switch; dirty git state blocking a checkout; missing `.env` (copy from `.env.example` and surface gaps).
|
||||
|
||||
### 4. External service / API change
|
||||
A service the agent depends on returns something unexpected. Find a workaround and capture it.
|
||||
|
||||
*Examples:* an MCP tool returns `InputValidationError` because the schema changed (patch the call shape); a public API hits a rate limit (back off, switch endpoint, batch); an upstream lib bumped a default and broke a script (pin the version).
|
||||
|
||||
### 5. About-to-retry-the-same-broken-approach
|
||||
The agent catches itself about to redo the failing step. That self-recognition is a heal forming — capture the alternate approach as the patch.
|
||||
|
||||
### Detection signals to watch for
|
||||
|
||||
- Non-zero exit codes
|
||||
- Stack traces in tool output
|
||||
- The same operation failing twice with the same error
|
||||
- "I'll try a different approach" — capture it as a heal
|
||||
- `command not found` / `module not found` / `permission denied`
|
||||
- Stale assertions, snapshot mismatches, type errors that weren't there before
|
||||
- "Weird" output that suggests environmental rather than logical bugs
|
||||
|
||||
## HEAL Entry Format
|
||||
|
||||
Append to `.learnings/HEALS.md` (create if missing):
|
||||
|
||||
```markdown
|
||||
## [HEAL-YYYYMMDD-XXX] short_kebab_name
|
||||
|
||||
**Logged**: ISO-8601 timestamp
|
||||
**Status**: verified | pending-verify | abandoned
|
||||
**Trigger**: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||
**Active-Context**: (optional) — current skill, task phase, or workflow stage; omit if not applicable
|
||||
**Area**: free-form tag — what part of the system (`build`, `tests`, `ci`, `auth`, `data-pipeline`, `mobile`, ...)
|
||||
**Priority**: low | medium | high | critical
|
||||
|
||||
### Failure
|
||||
What broke — concrete: the command, the error message, the action that was blocked. Include exit codes and verbatim error lines.
|
||||
|
||||
### Diagnosis
|
||||
The root cause as understood after investigation. Why the obvious approach didn't work. Not a guess — what was actually verified during the heal.
|
||||
|
||||
### Fix
|
||||
The patch that was applied. Verbatim commands, code snippets, or pointers to files under `.learnings/heals/<HEAL-ID>/`. Keep it minimal — just enough to reproduce.
|
||||
|
||||
### Verification
|
||||
What was run after the fix and what it returned. Exit code, output snippet, test pass count. **This is the proof.** Without it, the entry is `pending-verify` or `abandoned`.
|
||||
|
||||
### Artifacts
|
||||
(omit this section if no files were generated; otherwise list relative paths under `.learnings/heals/<HEAL-ID>/`)
|
||||
|
||||
### Metadata
|
||||
- Related Files: path/to/file.ext
|
||||
- See Also: HEAL-... | LRN-... | ERR-... (related entries)
|
||||
- Pattern-Key: lower.snake.case key for recurrence detection (e.g. `env.lockfile_mismatch`)
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen / Last-Seen: YYYY-MM-DD
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### Field guidance
|
||||
|
||||
- **Status** — `verified` = the verify step passed. `pending-verify` = patch applied but couldn't be fully proven (sandboxed/offline/CI-only) — surface to the user. `abandoned` = patch didn't work or diagnosis was wrong — document what was tried.
|
||||
- **Trigger** — free-form is fine. The listed values are common shapes; what matters is that the failure shape is described enough for future agents to match against.
|
||||
- **Active-Context** — optional. Use it if your environment has a meaningful "what was I doing" tag (an active skill, a current task phase, a build stage, an agent role). Skip if not applicable. The browser-harness analog is the per-domain scoping of `domain-skills/<site>/`.
|
||||
- **Area** — free-form. Pick whatever helps future agents find this. `frontend`, `data-pipeline`, `ci`, `auth`, `terraform`, `mobile`, `embedded` — anything that fits your project shape.
|
||||
- **Pattern-Key** — lower.snake.case, stable, reusable across projects. Two heals with the same key are recurrences. `env.lockfile_mismatch` is good; `fixed_thing_tuesday` isn't.
|
||||
|
||||
## ID generation
|
||||
|
||||
Format: `HEAL-YYYYMMDD-XXX`. `XXX` is sequential 3-digit or 3-char random alphanumeric. Examples: `HEAL-20260524-001`, `HEAL-20260524-A7B`.
|
||||
|
||||
## Artifacts directory (lazy)
|
||||
|
||||
Only create `.learnings/heals/<HEAL-ID>/` when the heal generated something worth preserving. One-line fixes don't need a folder; the HEAL entry text is enough. Abandoned heals with no applied patch also skip the folder.
|
||||
|
||||
```
|
||||
.learnings/
|
||||
├── HEALS.md
|
||||
├── ERRORS.md / LEARNINGS.md / FEATURE_REQUESTS.md (self-improvement)
|
||||
└── heals/
|
||||
└── HEAL-20260524-001/
|
||||
├── helper.sh
|
||||
├── patch.diff
|
||||
└── notes.md
|
||||
```
|
||||
|
||||
**Put here:** generated scripts/helpers, patch files, supplementary notes, output captures that document the diagnosis.
|
||||
**Don't put here:** project source changes (those go in the project tree, referenced via Related Files); secrets; output already captured in the HEAL text.
|
||||
|
||||
## Verification rules
|
||||
|
||||
Verify is the load-bearing wall. The whole point of self-healing over self-improvement is that the fix is *proven*, not theorized.
|
||||
|
||||
### What counts as proof
|
||||
|
||||
| Failure shape | Verification |
|
||||
| ------------------------------------- | ------------------------------------------------------------------ |
|
||||
| Tool / command / test / build / lint | Re-run the original invocation; expect exit 0 / pass |
|
||||
| Missing capability | Invoke the helper end-to-end on a real input; expect the intent |
|
||||
| Environment drift | Re-run the operation that triggered the diagnosis |
|
||||
| External service workaround | Re-run the failed call with the patch; expect a usable response |
|
||||
|
||||
### Sandboxed / offline / CI-only failures
|
||||
|
||||
When you genuinely can't run the verify step (no network, no real remote, sandboxed shell, CI-only reproduction), file `Status: pending-verify` with:
|
||||
|
||||
- The exact command the user / CI should run
|
||||
- The acceptance criteria — what counts as proof
|
||||
- A simulated proof if you can construct one (e.g. a dry-run mode, a stub of the failing call, a sandbox script)
|
||||
|
||||
`pending-verify` is honest. Faking `verified` is the failure mode this skill exists to prevent.
|
||||
|
||||
### When to invest in a proof script
|
||||
|
||||
Most heals don't need a separate proof script — the verify step is just re-running the failing thing. Build a proper proof script when:
|
||||
|
||||
- The heal generates a reusable helper that needs to be exercised across cases
|
||||
- The failure can't be reproduced live but can be reproduced in a sandbox (clean git repo, mock service, fake input)
|
||||
- You expect the heal to be re-applied across projects — the proof script then doubles as a regression check
|
||||
|
||||
### If verification fails
|
||||
|
||||
1. **Once** — refine the patch and retry. First diagnosis is often wrong.
|
||||
2. **Twice** — step back and reconsider the diagnosis. Maybe the root cause is elsewhere.
|
||||
3. **Three times** — stop. File `Status: abandoned` with notes on what you tried. Surface to the user. Don't flail.
|
||||
|
||||
### What does NOT count as verification
|
||||
|
||||
- "It looks right" / "I think this should work"
|
||||
- Re-running a *different* command than the one that originally failed
|
||||
- Suppressing the failure (`|| true`, `--ignore-errors`) — that's hiding
|
||||
- Skipping or deleting the failing test — that's regression
|
||||
- Passing because the cache was warm from before the fix
|
||||
|
||||
### Reversibility
|
||||
|
||||
Prefer reversible patches. If your heal modifies project files, capture the diff in `patch.diff`. If the heal is destructive (deletes generated files, rewrites locks), note it explicitly — a future agent reading the HEAL needs to know what was destroyed.
|
||||
|
||||
## Recurrence and promotion
|
||||
|
||||
Most heals are recurrences. Before filing a new HEAL, search:
|
||||
|
||||
```bash
|
||||
grep -n "Pattern-Key: <your-pattern-key>" .learnings/HEALS.md
|
||||
```
|
||||
|
||||
If found:
|
||||
|
||||
- Increment `Recurrence-Count`
|
||||
- Update `Last-Seen`
|
||||
- Add the current occurrence as a See Also link
|
||||
- **Do not** create a duplicate entry
|
||||
|
||||
### Promotion threshold
|
||||
|
||||
Add a `Handoff` block to an existing entry when **all** are true:
|
||||
|
||||
- `Recurrence-Count >= 3`
|
||||
- Seen across at least 2 distinct tasks
|
||||
- The fix is generalizable (not project-specific in a way that's already in a memory file)
|
||||
|
||||
```markdown
|
||||
### Handoff
|
||||
- **Promoted To**: self-improvement at YYYY-MM-DD
|
||||
- **Promotion Target**: CLAUDE.md | AGENTS.md | .github/copilot-instructions.md | new-skill
|
||||
- **Distilled Rule**: One-line prevention guidance derived from the heal
|
||||
```
|
||||
|
||||
Then `self-improvement` (or a learning aggregator) takes over: distills the rule, writes it into the right context file, or extracts a reusable skill. The HEAL stays for traceability.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
1. **Logging without verifying.** A HEAL filed before the fix is proven turns this into noisier self-improvement. If verify hasn't passed, the entry is `pending-verify` or `abandoned`.
|
||||
2. **Healing the symptom, not the cause.** A failing test isn't healed by skipping it (`pytest.skip`, `it.skip`, `xit`). A flaky CI isn't healed by `--retry`. Find the root cause; if you can't, abandon honestly.
|
||||
3. **Generating a new fix without trying existing ones first.** Search `HEALS.md` by Pattern-Key. Most heals are recurrences.
|
||||
4. **Inventing helpers when the project already has them.** Look in `scripts/`, `Makefile`, `justfile`, `package.json`, `pyproject.toml` first. Heal = write what's missing, not what's there.
|
||||
5. **Scope creep.** A heal is scoped to one failure. Cleanup belongs in a quality pass; refactors are features. Scope creep makes heals unreviewable.
|
||||
6. **Empty artifact folders.** Don't create `.learnings/heals/<HEAL-ID>/` if nothing goes in it.
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Heal eagerly, file always.** Even abandoned heals teach the next agent what doesn't work.
|
||||
2. **Verify before persist.** The non-negotiable rule.
|
||||
3. **Minimal and reversible patches.** A 3-line fix is a heal; a 300-line refactor is a feature.
|
||||
4. **Stable Pattern-Keys.** `env.node_version_mismatch` is reusable; `fixed_the_thing_on_tuesday` isn't.
|
||||
5. **Reference, don't duplicate.** Cross-link related HEAL/LRN/ERR via See Also.
|
||||
6. **Hand off recurrences.** A heal seen 3 times deserves to be in the project's permanent memory.
|
||||
7. **Don't gate the main tree on heal artifacts.** Files under `.learnings/heals/` are reference material; if a script becomes load-bearing, promote it to `scripts/`.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
mkdir -p .learnings # heals/ is lazy — created only when artifacts exist
|
||||
touch .learnings/HEALS.md
|
||||
```
|
||||
|
||||
Gitignore choices match `self-improvement`. Keep heals local (`.learnings/` in `.gitignore`) or share them as team knowledge (don't gitignore — they become reviewable durable context).
|
||||
|
||||
## Multi-agent use
|
||||
|
||||
The skill is agent-agnostic. The `.learnings/HEALS.md` format is plain markdown — any agent (Claude Code, BooCode agents, OpenCode, Copilot, Cursor, Aider, ...) can read and write it.
|
||||
|
||||
## See also
|
||||
|
||||
- [`references/examples.md`](references/examples.md) — canonical HEAL entry shapes (command failure, missing capability, env drift, external API workaround, abandoned heal)
|
||||
35
data/skills/boocode/self-healing/eval.yaml
Normal file
35
data/skills/boocode/self-healing/eval.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
skill: self-healing
|
||||
tasks:
|
||||
- prompt: "I'm in a project root that has pnpm-lock.yaml present but no package-lock.json. I just tried to run `npm install` and it failed. Get me to a working state so I can keep working — I have other things to do, just unblock me. After fixing it, make sure future agents in this project know what happened."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response diagnoses pnpm vs npm mismatch as the root cause
|
||||
- the response runs pnpm install successfully
|
||||
- the response files a HEAL entry to .learnings/HEALS.md with Status: verified
|
||||
- the HEAL entry has Trigger: tool-failure
|
||||
- the HEAL entry has a Pattern-Key resembling env.lockfile_mismatch
|
||||
- the HEAL entry includes the verification output
|
||||
- prompt: "I need to bulk-rename 8 git branches in this repo from `feat-XXX-name` to `feat/XXX-name`. There's no existing script for this and `gh` doesn't have a bulk-rename. Write what's needed, prove it works on a dry run, and capture the work so it's not lost if I need it again."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response recognizes this as a missing-capability heal
|
||||
- the response writes a helper script under .learnings/heals/HEAL-<date>-<seq>/
|
||||
- the response runs a dry-run verification
|
||||
- the response files a HEAL entry with Status: verified and Trigger: missing-capability
|
||||
- the HEAL entry references the helper script in the Artifacts section
|
||||
- prompt: "I just ran `pytest` and got `ModuleNotFoundError: No module named 'pydantic'`. There's already a `.learnings/HEALS.md` in this project with a prior heal for a similar venv-not-activated issue. Fix this, and do the right thing with the heal records."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response searches HEALS.md first (using find-similar-heals.sh or grep) before writing a new fix
|
||||
- the response finds the existing HEAL entry and applies its fix (activate venv)
|
||||
- the response increments Recurrence-Count on the existing entry
|
||||
- the response updates Last-Seen on the existing entry
|
||||
- the response does NOT create a duplicate HEAL entry
|
||||
- prompt: "A test in this repo is failing intermittently — the snapshot for `Card.test.tsx` flakes. I've already tried fixing it once by stubbing the date; it passes twice then flakes again because there's a UUID that's also non-deterministic. I don't have time to refactor the Card component to inject dependencies. Just do the right thing — get me to a state that's honest about what's known and not known, and don't pretend the heal worked."
|
||||
grader:
|
||||
- the response invokes the self-healing skill
|
||||
- the response diagnoses that the initial patch attempt was incomplete
|
||||
- the response files a HEAL entry with Status: abandoned
|
||||
- the HEAL entry documents what was tried and why it failed
|
||||
- the response does NOT mark anything as verified
|
||||
- the response surfaces the situation honestly to the user
|
||||
248
data/skills/boocode/self-healing/references/examples.md
Normal file
248
data/skills/boocode/self-healing/references/examples.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Self-Healing Examples
|
||||
|
||||
Concrete HEAL entries showing the format applied to real failure shapes. Use these as templates when filing your own heals. All examples use the iteration-2 schema (free-form `Trigger` / `Area`, optional `Active-Context`, no `Source` field, lazy artifact folders).
|
||||
|
||||
---
|
||||
|
||||
## Example 1 — Tool failure (lockfile mismatch)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-001] npm_install_pnpm_lockfile
|
||||
|
||||
**Logged**: 2026-05-24T14:22:01Z
|
||||
**Status**: verified
|
||||
**Trigger**: tool-failure
|
||||
**Area**: build
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
`npm install` exited 1 with `npm ERR! code EUSAGE` and a notice that `pnpm-lock.yaml` is present but `package-lock.json` is missing. The project uses pnpm workspaces; npm refuses to install against a pnpm lockfile.
|
||||
|
||||
### Diagnosis
|
||||
Project root contains `pnpm-lock.yaml`. The README and CI both invoke `pnpm`. `npm` was a habit from previous projects, not the actual project's package manager.
|
||||
|
||||
### Fix
|
||||
Use pnpm instead:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Verification
|
||||
```
|
||||
$ pnpm install
|
||||
Lockfile is up to date, resolution step is skipped
|
||||
Already up to date
|
||||
✓ Done in 1.4s
|
||||
```
|
||||
Exit 0.
|
||||
|
||||
### Metadata
|
||||
- Related Files: package.json, pnpm-lock.yaml
|
||||
- See Also: (none yet)
|
||||
- Pattern-Key: env.lockfile_mismatch
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Pattern-Key `env.lockfile_mismatch` is reusable across projects (yarn.lock, bun.lockb, etc.). At Recurrence ≥ 3, this should be promoted to `CLAUDE.md` or `AGENTS.md` as a verification step.
|
||||
|
||||
No Artifacts section — the fix is a tool swap, no files generated. Lazy folder pattern: nothing to put in `.learnings/heals/HEAL-20260524-001/`, so the folder isn't created.
|
||||
|
||||
---
|
||||
|
||||
## Example 2 — Missing capability (helper written on the fly)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-002] bulk_rename_branches_helper
|
||||
|
||||
**Logged**: 2026-05-24T15:10:44Z
|
||||
**Status**: verified
|
||||
**Trigger**: missing-capability
|
||||
**Area**: ci
|
||||
**Priority**: low
|
||||
|
||||
### Failure
|
||||
Need to rename 12 feature branches from `feat-XXX-name` to `feat/XXX-name`. No existing project script handles this; `gh` doesn't have a bulk-rename primitive.
|
||||
|
||||
### Diagnosis
|
||||
This is glue work, not a project bug. A small shell helper using `gh api` per branch is the right level — not worth a top-level script, but worth keeping the file for the next time someone asks.
|
||||
|
||||
### Fix
|
||||
Wrote `.learnings/heals/HEAL-20260524-002/rename-branches.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
git fetch --all
|
||||
for branch in $(git branch -r | grep 'origin/feat-' | sed 's|origin/||'); do
|
||||
new="${branch/feat-/feat/}"
|
||||
echo "$branch → $new"
|
||||
gh api -X POST "repos/{owner}/{repo}/git/refs" \
|
||||
-f "ref=refs/heads/$new" \
|
||||
-f "sha=$(git rev-parse "origin/$branch")"
|
||||
gh api -X DELETE "repos/{owner}/{repo}/git/refs/heads/$branch"
|
||||
done
|
||||
```
|
||||
|
||||
### Verification
|
||||
Dry-run (commented out the API calls) printed the 12 expected mappings.
|
||||
Live run renamed all 12; `git branch -r | grep 'feat-' | wc -l` returns 0.
|
||||
|
||||
### Artifacts
|
||||
- `.learnings/heals/HEAL-20260524-002/rename-branches.sh`
|
||||
|
||||
### Metadata
|
||||
- Related Files: (none — operates on git refs)
|
||||
- See Also: (none)
|
||||
- Pattern-Key: tool.gh.bulk_branch_rename
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Helper script lives under `.learnings/heals/<HEAL-ID>/` — referenceable, but not assumed to be load-bearing. If it gets reused frequently, promote to `scripts/`.
|
||||
|
||||
---
|
||||
|
||||
## Example 3 — Environment issue (runtime version)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-003] nvm_use_project_node
|
||||
|
||||
**Logged**: 2026-05-24T16:01:12Z
|
||||
**Status**: verified
|
||||
**Trigger**: env-issue
|
||||
**Active-Context**: verify-gate
|
||||
**Area**: tests
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
`pnpm test` exited 1 with `engine "node" is incompatible with this module. Expected version "^20.10.0". Got "18.19.0"`.
|
||||
|
||||
### Diagnosis
|
||||
`.nvmrc` requests node 20.10.0; current shell has 18.19.0 from a previous project context. The shell's nvm wasn't switched after `cd`-ing into the repo.
|
||||
|
||||
### Fix
|
||||
```bash
|
||||
nvm use # reads .nvmrc
|
||||
```
|
||||
|
||||
### Verification
|
||||
```
|
||||
$ node --version
|
||||
v20.10.0
|
||||
$ pnpm test
|
||||
✓ 47 tests passed
|
||||
```
|
||||
|
||||
### Metadata
|
||||
- Related Files: .nvmrc, package.json
|
||||
- See Also: (none)
|
||||
- Pattern-Key: env.node_version_mismatch
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
`Active-Context: verify-gate` because that's the workflow phase the agent was in when the test step blew up. An upstream context loader could surface this entry next time `verify-gate` runs in a node project. If you don't have an analogous concept in your pipeline, omit the field.
|
||||
|
||||
---
|
||||
|
||||
## Example 4 — External service workaround
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-004] gh_api_rate_limit_backoff
|
||||
|
||||
**Logged**: 2026-05-24T17:33:08Z
|
||||
**Status**: verified
|
||||
**Trigger**: external-change
|
||||
**Area**: ci
|
||||
**Priority**: high
|
||||
|
||||
### Failure
|
||||
Looping `gh api repos/.../issues` over 200 issues started returning `403 rate limit exceeded` after ~60 calls. Unauthenticated burst limit (abuse detection on rapid successive calls).
|
||||
|
||||
### Diagnosis
|
||||
Script was using `gh api` REST without batching. `gh` is authenticated but the secondary rate limit fires on rapid successive calls — not the primary 5000/hour limit. Switching to a single paginated GraphQL query bypasses the secondary limit entirely.
|
||||
|
||||
### Fix
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$cursor:String) {
|
||||
repository(owner:$owner,name:$repo) {
|
||||
issues(first:100,after:$cursor) { ... }
|
||||
}
|
||||
}' -F owner=... -F repo=...
|
||||
```
|
||||
Took ~3 calls total instead of 200.
|
||||
|
||||
### Verification
|
||||
Full run completed in 4.8s, no 403s, all 200 issues retrieved. Compared output against a sample of the original per-issue calls — fields match.
|
||||
|
||||
### Artifacts
|
||||
- `.learnings/heals/HEAL-20260524-004/fetch-issues.sh`
|
||||
|
||||
### Metadata
|
||||
- Related Files: (none — ad-hoc query)
|
||||
- See Also: (none)
|
||||
- Pattern-Key: api.gh.rate_limit
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 5 — Abandoned heal (diagnosis was wrong)
|
||||
|
||||
```markdown
|
||||
## [HEAL-20260524-005] vitest_flaky_snapshot
|
||||
|
||||
**Logged**: 2026-05-24T18:14:22Z
|
||||
**Status**: abandoned
|
||||
**Trigger**: tool-failure
|
||||
**Active-Context**: verify-gate
|
||||
**Area**: tests
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
`vitest` snapshot test `Card > renders default` flaked twice in three runs. Diff showed a timestamp string differing by ~3 seconds.
|
||||
|
||||
### Diagnosis (initial — wrong)
|
||||
Assumed flake was timezone drift in the snapshot fixture. Patched the fixture to use a fixed `Date.now()` stub.
|
||||
|
||||
### Diagnosis (current — correct)
|
||||
The snapshot depends on multiple non-deterministic values: timestamp AND a `crypto.randomUUID()`. The clock stub addressed only one of them. The UUID is still random per render, so the snapshot keeps drifting on subsequent runs.
|
||||
|
||||
### Fix (attempted)
|
||||
Added `vi.useFakeTimers({ now: 1700000000000 })` to the test setup.
|
||||
|
||||
### Verification
|
||||
Test passed twice, then flaked again on the third run — same `Card > renders default`, different diff (this time the UUID changed). Original diagnosis was incomplete.
|
||||
|
||||
### Abandonment notes
|
||||
The right fix is to make the component deterministic via dependency injection (pass a `clock` and `idGen` prop), not to stub globally. That's a real change to the component contract — out of scope for a heal. Filed `FEAT-20260524-001` via self-improvement; surfaced to the user.
|
||||
|
||||
### Metadata
|
||||
- Related Files: src/components/Card.tsx, src/components/Card.test.tsx
|
||||
- See Also: FEAT-20260524-001
|
||||
- Pattern-Key: tests.flaky_snapshot_multi_nondeterminism
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: 2026-05-24
|
||||
- Last-Seen: 2026-05-24
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Abandoned heals are first-class. They document a dead end so the next agent doesn't re-walk it. The handoff to a `FEAT-` entry via self-improvement is the right next step when the real fix is a feature, not a heal.
|
||||
|
||||
No Artifacts section — the attempted patch was reverted; nothing reusable was generated.
|
||||
54
data/skills/boocode/self-healing/scripts/detect-failure.sh
Executable file
54
data/skills/boocode/self-healing/scripts/detect-failure.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# detect-failure.sh — PostToolUse hook for Bash invocations.
|
||||
# Reads the tool result JSON on stdin (per Claude Code hook spec); if exit_code != 0,
|
||||
# emits a system reminder pointing the agent at self-healing.
|
||||
#
|
||||
# Wire up in .claude/settings.json:
|
||||
# "hooks": {
|
||||
# "PostToolUse": [{ "matcher": "Bash",
|
||||
# "hooks": [{ "type": "command",
|
||||
# "command": "./data/skills/boocode/self-healing/scripts/detect-failure.sh" }] }]
|
||||
# }
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Hook payload arrives on stdin. We tolerate either jq-style JSON or raw text.
|
||||
PAYLOAD="$(cat || true)"
|
||||
|
||||
# Try to parse exit_code; fall through silently on parse failure.
|
||||
EXIT_CODE=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read() or "{}")
|
||||
# Common shapes: {"tool_result": {"exit_code": N}}, {"exit_code": N}, {"output": "...", "exit_code": N}
|
||||
for path in (("tool_result","exit_code"), ("exit_code",), ("result","exit_code")):
|
||||
d = data
|
||||
ok = True
|
||||
for k in path:
|
||||
if isinstance(d, dict) and k in d:
|
||||
d = d[k]
|
||||
else:
|
||||
ok = False
|
||||
break
|
||||
if ok and isinstance(d, int):
|
||||
print(d)
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
print(0)
|
||||
' 2>/dev/null || echo 0)
|
||||
|
||||
if [[ "$EXIT_CODE" != "0" ]]; then
|
||||
cat <<'EOF'
|
||||
<self-healing-trigger>
|
||||
A Bash command just exited non-zero. This is a heal opportunity.
|
||||
|
||||
Before retrying the same command verbatim:
|
||||
1. DIAGNOSE — read the error; identify the root cause (env? missing dep? wrong tool?)
|
||||
2. Search .learnings/HEALS.md for a matching Pattern-Key (don't re-solve a solved problem)
|
||||
3. PATCH — write the fix (or apply a known one)
|
||||
4. VERIFY — re-run the command; require exit 0
|
||||
5. FILE — append a HEAL entry to .learnings/HEALS.md via data/skills/boocode/self-healing/scripts/new-heal.sh
|
||||
</self-healing-trigger>
|
||||
EOF
|
||||
fi
|
||||
52
data/skills/boocode/self-healing/scripts/find-similar-heals.sh
Executable file
52
data/skills/boocode/self-healing/scripts/find-similar-heals.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# find-similar-heals.sh — Search existing heals before generating a new fix.
|
||||
# Usage: ./find-similar-heals.sh <pattern-key-or-keyword>
|
||||
#
|
||||
# Prints matching HEAL entries with their Pattern-Key, Status, and Recurrence-Count
|
||||
# so the agent can decide whether to re-apply an existing fix or write a new one.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QUERY="${1:-}"
|
||||
HEALS_FILE="$(pwd)/.learnings/HEALS.md"
|
||||
|
||||
if [[ -z "$QUERY" ]]; then
|
||||
echo "usage: $0 <pattern-key-or-keyword>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HEALS_FILE" ]]; then
|
||||
echo "(no .learnings/HEALS.md yet — no prior heals to consult)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find HEAL section headers that contain the query in their body (Pattern-Key, name, or text).
|
||||
python3 - <<PY "$QUERY" "$HEALS_FILE"
|
||||
import sys, re
|
||||
query, path = sys.argv[1].lower(), sys.argv[2]
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
# Split into entries by ^## [HEAL-...]
|
||||
entries = re.split(r"(?m)^## \[HEAL-", text)[1:]
|
||||
hits = []
|
||||
for body in entries:
|
||||
if query in body.lower():
|
||||
head = body.splitlines()[0]
|
||||
pk = re.search(r"Pattern-Key:\s*(\S+)", body)
|
||||
status = re.search(r"Status\*\*:\s*(\S+)", body) or re.search(r"Status:\s*(\S+)", body)
|
||||
rc = re.search(r"Recurrence-Count:\s*(\d+)", body)
|
||||
hits.append({
|
||||
"id": "HEAL-" + head.split("]")[0],
|
||||
"name": head.split("]", 1)[1].strip() if "]" in head else head,
|
||||
"pattern_key": pk.group(1) if pk else "?",
|
||||
"status": status.group(1) if status else "?",
|
||||
"recurrence": rc.group(1) if rc else "1",
|
||||
})
|
||||
if not hits:
|
||||
print(f"(no heals match '{query}')")
|
||||
else:
|
||||
print(f"Found {len(hits)} matching heal(s):\n")
|
||||
for h in hits:
|
||||
print(f" {h['id']} {h['name']}")
|
||||
print(f" pattern={h['pattern_key']} status={h['status']} recurrence={h['recurrence']}")
|
||||
PY
|
||||
74
data/skills/boocode/self-healing/scripts/new-heal.sh
Executable file
74
data/skills/boocode/self-healing/scripts/new-heal.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# new-heal.sh — Initialize a new HEAL-<date>-<seq> entry skeleton.
|
||||
# Usage: ./new-heal.sh <short_kebab_name> [trigger]
|
||||
# trigger: tool-failure | missing-capability | env-issue | external-change | <free-form>
|
||||
#
|
||||
# Appends a templated HEAL entry to .learnings/HEALS.md and prints the HEAL-ID.
|
||||
# Does NOT create .learnings/heals/<HEAL-ID>/ — that folder is lazy, created
|
||||
# only when artifacts are written.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:-}"
|
||||
TRIGGER="${2:-tool-failure}"
|
||||
|
||||
if [[ -z "$NAME" ]]; then
|
||||
echo "usage: $0 <short_kebab_name> [trigger]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LEARNINGS_DIR="$(pwd)/.learnings"
|
||||
HEALS_FILE="$LEARNINGS_DIR/HEALS.md"
|
||||
mkdir -p "$LEARNINGS_DIR"
|
||||
|
||||
DATE="$(date +%Y%m%d)"
|
||||
SEQ=$(grep -c "^## \[HEAL-${DATE}-" "$HEALS_FILE" 2>/dev/null || echo 0)
|
||||
NEXT=$(printf "%03d" $((SEQ + 1)))
|
||||
HEAL_ID="HEAL-${DATE}-${NEXT}"
|
||||
|
||||
# Active-Context is optional. The agent / harness can set ACTIVE_CONTEXT in env.
|
||||
ACTIVE_CONTEXT="${ACTIVE_CONTEXT:-}"
|
||||
ACTIVE_LINE=""
|
||||
if [[ -n "$ACTIVE_CONTEXT" ]]; then
|
||||
ACTIVE_LINE="**Active-Context**: $ACTIVE_CONTEXT
|
||||
"
|
||||
fi
|
||||
|
||||
cat >> "$HEALS_FILE" <<EOF
|
||||
|
||||
## [$HEAL_ID] $NAME
|
||||
|
||||
**Logged**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
**Status**: pending-verify
|
||||
**Trigger**: $TRIGGER
|
||||
${ACTIVE_LINE}**Area**: TODO
|
||||
**Priority**: medium
|
||||
|
||||
### Failure
|
||||
TODO — concrete error, command, exit code
|
||||
|
||||
### Diagnosis
|
||||
TODO — root cause after investigation
|
||||
|
||||
### Fix
|
||||
TODO — patch applied (commands, snippets, or pointers to .learnings/heals/$HEAL_ID/ if files were generated)
|
||||
|
||||
### Verification
|
||||
TODO — what was run after the fix, what it returned. **Update Status to "verified" only after this passes.**
|
||||
|
||||
### Metadata
|
||||
- Related Files: TODO
|
||||
- See Also: TODO
|
||||
- Pattern-Key: TODO
|
||||
- Recurrence-Count: 1
|
||||
- First-Seen: $(date +%Y-%m-%d)
|
||||
- Last-Seen: $(date +%Y-%m-%d)
|
||||
|
||||
---
|
||||
EOF
|
||||
|
||||
# stdout = the HEAL-ID alone, so `ID=$(new-heal.sh ...)` captures it cleanly.
|
||||
# Human guidance goes to stderr.
|
||||
echo "$HEAL_ID"
|
||||
echo "$HEALS_FILE" >&2
|
||||
echo "(create .learnings/heals/$HEAL_ID/ only if you generate artifacts to put there)" >&2
|
||||
178
data/skills/boocode/verify-gate/SKILL.md
Normal file
178
data/skills/boocode/verify-gate/SKILL.md
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: verify-gate
|
||||
description: "Runs project compile, test, and lint commands between implementation and quality review. Gates simplify-and-harden behind machine verification. If checks fail, enters a fix loop with diagnostics. If checks pass, signals ready for quality pass. Use after any implementation work completes and before signaling done. Essential for the inner loop's verify step."
|
||||
---
|
||||
|
||||
# Verify Gate
|
||||
|
||||
Machine verification gate between implementation and quality review. Runs the project's compile, test, and lint commands. If any fail, enters a fix loop. If all pass, unblocks the quality pass.
|
||||
|
||||
This is the inner loop's **verify** step. Without it, the agent hands off code with zero machine signal about whether it actually works.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After any implementation work completes, before signaling "done"
|
||||
- Before running simplify-and-harden or quality review
|
||||
- After fixing audit findings from code review
|
||||
- Any time you want a machine-verified green signal
|
||||
|
||||
## Pipeline Position
|
||||
|
||||
```
|
||||
[implementation] → verify-gate → [quality review / simplify-and-harden]
|
||||
↻ fix loop — on failure, diagnose and retry
|
||||
```
|
||||
|
||||
## Step 1: Discover Project Commands
|
||||
|
||||
Read the project's configuration to find verification commands. Check these sources in order:
|
||||
|
||||
1. **Project instruction files** (`CLAUDE.md`, `data/AGENTS.md`) — look for a `## Verification` or `## Test Commands` section
|
||||
2. **package.json** — `scripts.test`, `scripts.lint`, `scripts.typecheck`, `scripts.build`. BooCode uses pnpm, so prefer `pnpm run <script>` when `pnpm-lock.yaml` is present.
|
||||
3. **Makefile** / **Justfile** — `test`, `lint`, `check`, `build` targets
|
||||
4. **Cargo.toml** — `cargo build`, `cargo test`, `cargo clippy`
|
||||
5. **pyproject.toml** / **setup.cfg** — `pytest`, `mypy`, `ruff`
|
||||
6. **go.mod** — `go build ./...`, `go test ./...`, `go vet ./...`
|
||||
7. **deno.json** / **deno.jsonc** — `deno task <name>` for any defined tasks
|
||||
|
||||
If no commands are discoverable, ask the user once and suggest they add a `## Verification` section to `CLAUDE.md` for future sessions:
|
||||
|
||||
```markdown
|
||||
## Verification
|
||||
|
||||
- Build: `pnpm run build`
|
||||
- Test: `pnpm test`
|
||||
- Lint: `pnpm run lint`
|
||||
- Type check: `npx tsc -p apps/server/tsconfig.json --noEmit`
|
||||
```
|
||||
|
||||
## Step 2: Run Verification
|
||||
|
||||
Run discovered commands in this order. Stop at the first failure category.
|
||||
|
||||
### Phase 1: Compile / Type Check
|
||||
Run the build or type-check command. These catch structural errors before wasting time on tests.
|
||||
|
||||
```
|
||||
Exit 0 → proceed to Phase 2
|
||||
Exit non-zero → enter fix loop with compiler output
|
||||
```
|
||||
|
||||
### Phase 2: Tests
|
||||
Run the test command. Scope to changed files if the test runner supports it.
|
||||
|
||||
```
|
||||
Exit 0 → proceed to Phase 3
|
||||
Exit non-zero → enter fix loop with test output
|
||||
```
|
||||
|
||||
### Phase 3: Lint (optional, skippable with --skip-lint)
|
||||
Run the lint command. Lint failures are lower severity but still worth catching.
|
||||
|
||||
```
|
||||
Exit 0 → all phases green, gate passes
|
||||
Exit non-zero → enter fix loop with lint output
|
||||
```
|
||||
|
||||
## Step 3: Fix Loop
|
||||
|
||||
When a phase fails:
|
||||
|
||||
1. **Read the output.** Parse the error output for actionable diagnostics — file paths, line numbers, error messages.
|
||||
2. **Scope the fix.** Only fix what the verification caught. Do not refactor, improve, or touch unrelated code.
|
||||
3. **Apply the fix.** Make the minimal change to resolve the failure.
|
||||
4. **Re-run the failed phase.** Not all phases — just the one that failed.
|
||||
5. **If it passes**, continue to the next phase.
|
||||
6. **If it fails again**, increment the attempt counter.
|
||||
|
||||
### Fix Loop Limits
|
||||
|
||||
- **Default max attempts:** 3 per phase (configurable via `--fix-limit N`)
|
||||
- **Counter increments on every attempt**, even if the error changes. Fixing Error A and uncovering Error B counts as attempt 2, not attempt 1. The counter tracks fix attempts, not unique errors.
|
||||
- **If limit reached:** Stop. Report what failed, what was tried, and the remaining error output. Do not guess further — signal to the user that manual intervention is needed.
|
||||
- **Total budget:** The fix loop should not exceed 20% of the original implementation effort. If fixes are snowballing, stop and report.
|
||||
|
||||
## Step 4: Gate Signal
|
||||
|
||||
When all phases pass:
|
||||
|
||||
```markdown
|
||||
## Verify Gate: PASSED
|
||||
|
||||
- Build: passed
|
||||
- Tests: passed (N tests, M suites)
|
||||
- Lint: passed (or skipped)
|
||||
|
||||
Ready for quality review.
|
||||
```
|
||||
|
||||
When the fix loop is exhausted:
|
||||
|
||||
```markdown
|
||||
## Verify Gate: BLOCKED
|
||||
|
||||
- Build: passed
|
||||
- Tests: FAILED (attempt 3/3)
|
||||
- [file:line] error description
|
||||
- [file:line] error description
|
||||
- Lint: not reached
|
||||
|
||||
Fix loop exhausted. Manual intervention needed before quality review.
|
||||
```
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
### boocode simplify-and-harden / quality review
|
||||
verify-gate should gate any quality pass. Run verify-gate first; only proceed to review if the gate passes.
|
||||
|
||||
### self-healing (if available)
|
||||
On any failure during the verify run, consider handing the diagnostics to a self-healing loop (diagnose → patch → verify → persist). Verify-gate then re-runs the checks. Up to 3 heal attempts per phase before abandoning.
|
||||
|
||||
### self-improvement
|
||||
If a recurring error pattern emerges across verify runs, capture it in `CLAUDE.md` or as a new skill under `data/skills/boocode/` so future verify-gate runs don't rediscover the same fix.
|
||||
|
||||
## What This Skill Does NOT Do
|
||||
|
||||
- Does not review code quality (that's a separate review pass)
|
||||
- Does not check security
|
||||
- Does not verify spec compliance
|
||||
- Does not modify test files or add new tests
|
||||
- Does not run tests for code it didn't change (unless the test runner doesn't support scoping)
|
||||
|
||||
## Configuration
|
||||
|
||||
If the project has a `verify-gate` section in `CLAUDE.md` or `data/AGENTS.md`:
|
||||
|
||||
```yaml
|
||||
## Verify Gate Config
|
||||
|
||||
build: pnpm run build
|
||||
test: pnpm test
|
||||
lint: pnpm run lint
|
||||
type_check: npx tsc -p apps/server/tsconfig.json --noEmit
|
||||
fix_limit: 3
|
||||
skip_lint: false
|
||||
test_scope: changed # changed | all
|
||||
```
|
||||
|
||||
If no configuration exists, discover commands automatically (Step 1) and suggest persisting them.
|
||||
|
||||
### Custom Verification Steps
|
||||
|
||||
Projects with custom invariants can define additional verification phases. These run as extra phases after the standard compile/test/lint checks.
|
||||
|
||||
Example — a project that needs API schema validation:
|
||||
|
||||
```yaml
|
||||
## Verify Gate Config
|
||||
|
||||
custom_checks:
|
||||
- name: validate-schema
|
||||
command: python scripts/validate_schema.py --strict
|
||||
- name: check-no-legacy-imports
|
||||
command: grep -r "from legacy" src/ --include="*.py" && exit 1 || exit 0
|
||||
```
|
||||
|
||||
When custom checks are defined, verify-gate runs them as **Phase 4** after lint. Each check's exit code determines pass/fail. Failed checks enter the same fix loop as standard phases.
|
||||
|
||||
This moves project-specific invariants from "knowledge in your head" to "knowledge in the harness" — exactly where the agent can reach it.
|
||||
160
scripts/omo-paseo-bridge.sh
Executable file
160
scripts/omo-paseo-bridge.sh
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# omo-paseo-bridge.sh — Import OMO task() child sessions as Paseo agents
|
||||
#
|
||||
# Automates calling `paseo import` on child session IDs so OMO subagents
|
||||
# appear in `paseo ls` alongside native Paseo agents.
|
||||
#
|
||||
# Usage:
|
||||
# omo-paseo-bridge.sh import [--type <category>] <session-id>...
|
||||
# Import session(s) as Paseo agents with omo=true labels
|
||||
#
|
||||
# omo-paseo-bridge.sh archive <agent-id>...
|
||||
# Archive (soft-delete) agent(s) imported by this bridge
|
||||
#
|
||||
# omo-paseo-bridge.sh ls [--all]
|
||||
# List agents tagged omo=true via paseo ls
|
||||
#
|
||||
# omo-paseo-bridge.sh --dry-run <command> ...
|
||||
# Print what would be done without executing
|
||||
#
|
||||
# Examples:
|
||||
# omo-paseo-bridge.sh import ses_abc123 ses_def456
|
||||
# omo-paseo-bridge.sh import --type research ses_abc123
|
||||
# omo-paseo-bridge.sh archive agt_789
|
||||
# omo-paseo-bridge.sh ls
|
||||
# omo-paseo-bridge.sh --dry-run import ses_abc123
|
||||
# =============================================================================
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
PASEO="$(which paseo 2>/dev/null || echo "paseo")"
|
||||
DRY_RUN=false
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { printf "[%s] %s\n" "$SCRIPT_NAME" "$*"; }
|
||||
warn() { printf "[%s] WARNING: %s\n" "$SCRIPT_NAME" "$*" >&2; }
|
||||
err() { printf "[%s] ERROR: %s\n" "$SCRIPT_NAME" "$*" >&2; exit 1; }
|
||||
|
||||
paseo_cmd() {
|
||||
if $DRY_RUN; then
|
||||
log "[DRY-RUN] would run: $PASEO $*"
|
||||
return 0
|
||||
fi
|
||||
"$PASEO" "$@" 2>&1 || warn "'paseo $*' exited with code $?"
|
||||
}
|
||||
|
||||
paseo_import() {
|
||||
local session_id="$1"
|
||||
shift
|
||||
local type_label="${1:-}"
|
||||
local labels=("--label" "omo=true")
|
||||
|
||||
# Add parent session label if OMO_SESSION_ID is set (injected by agent)
|
||||
if [[ -n "${OMO_SESSION_ID:-}" ]]; then
|
||||
labels+=("--label" "parent=${OMO_SESSION_ID}")
|
||||
fi
|
||||
|
||||
if [[ -n "$type_label" ]]; then
|
||||
labels+=("--label" "type=${type_label}")
|
||||
fi
|
||||
|
||||
log "Importing session ${session_id} as Paseo agent ..."
|
||||
paseo_cmd import "$session_id" --provider opencode "${labels[@]}"
|
||||
}
|
||||
|
||||
paseo_archive() {
|
||||
local agent_id="$1"
|
||||
log "Archiving agent ${agent_id} ..."
|
||||
paseo_cmd archive "$agent_id" --force
|
||||
}
|
||||
|
||||
paseo_list() {
|
||||
local all_flag="${1:-}"
|
||||
if [[ "$all_flag" == "--all" ]]; then
|
||||
paseo_cmd ls --label "omo=true" --all
|
||||
else
|
||||
paseo_cmd ls --label "omo=true"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── usage ────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $SCRIPT_NAME [--dry-run] <command> [options] [args...]
|
||||
|
||||
Commands:
|
||||
import [--type <category>] <session-id>...
|
||||
Import OMO child session(s) as Paseo agents
|
||||
archive <agent-id>...
|
||||
Archive Paseo agent(s) (soft-delete)
|
||||
ls [--all]
|
||||
List agents tagged omo=true
|
||||
|
||||
Options:
|
||||
--dry-run Print actions without executing them
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
$SCRIPT_NAME import --type research ses_abc123
|
||||
$SCRIPT_NAME archive agt_789
|
||||
$SCRIPT_NAME ls --all
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Peel off global flags
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $# -eq 0 ]] && usage
|
||||
|
||||
COMMAND="$1"
|
||||
shift
|
||||
|
||||
case "$COMMAND" in
|
||||
import)
|
||||
TYPE_LABEL=""
|
||||
SESSION_IDS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--type) TYPE_LABEL="$2"; shift 2 ;;
|
||||
--type=*) TYPE_LABEL="${1#*=}"; shift ;;
|
||||
-*) err "Unknown option for import: $1" ;;
|
||||
*) SESSION_IDS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ ${#SESSION_IDS[@]} -eq 0 ]] && err "import requires at least one session-id"
|
||||
|
||||
for sid in "${SESSION_IDS[@]}"; do
|
||||
paseo_import "$sid" "$TYPE_LABEL"
|
||||
done
|
||||
;;
|
||||
|
||||
archive)
|
||||
[[ $# -eq 0 ]] && err "archive requires at least one agent-id"
|
||||
for aid in "$@"; do
|
||||
paseo_archive "$aid"
|
||||
done
|
||||
;;
|
||||
|
||||
ls)
|
||||
paseo_list "${1:-}"
|
||||
;;
|
||||
|
||||
*)
|
||||
err "Unknown command: $COMMAND\n$(usage)"
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user