Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 5 — Context bundle & agent interface
- **Next step:** 5.2Context-bundle contract
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1
- **Next step:** 5.3Blast radius (reverse traversal)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.2
- **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000)

## What CodeRadar is
Expand Down Expand Up @@ -274,7 +274,7 @@ The heart of the project. C1 and B1 live here.
- Runs the matching engine with all available signals; merges rankings.
**Accept:** `eval/tickets/` suite (≥ 15 hand-written tickets: 5 visual, 4 textual, 3 behavioral, 3 out-of-domain) — OOD rejection ≥ 0.95, entry-point classification accuracy ≥ 0.90.

### [ ] 5.2 Context-bundle contract
### [x] 5.2 Context-bundle contract
**Failure modes:** F1
**Build:** `ContextBundle` type + JSON Schema (committed, drift-gated): sections `match` (instances + evidence), `lineage` (per-instance sources with patterns/methods), `journeys` (slice around the match, depth 2 default), `blastRadius`, `tests`, `history`, `warnings` (staleness, incomplete flags). Budgeter: `budgetTokens` param; sections trimmed in fixed priority order (match > lineage > blastRadius > journeys > tests > history), each trim recorded in `warnings`.
**Accept:** golden bundles for 5 ticket evals; every bundle ≤ budget under a tokenizer test at budgets 2k/4k/8k; trim order unit-tested.
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
"test": "vitest run",
"schema": "node scripts/gen-schema.mjs"
},
"dependencies": {
"@coderadar/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
"ts-json-schema-generator": "^2.9.0",
"typescript": "^5.7.0",
"vitest": "^3.2.7"
}
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-sdk/scripts/gen-schema.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Regenerate schemas/context-bundle.schema.json from the TS types. */
import fs from "node:fs";
import path from "node:path";

import { createGenerator } from "ts-json-schema-generator";

import { generatorConfig, schemaOutPath } from "./schema-config.mjs";

const schema = createGenerator(generatorConfig).createSchema(generatorConfig.type);
fs.mkdirSync(path.dirname(schemaOutPath), { recursive: true });
fs.writeFileSync(schemaOutPath, JSON.stringify(schema, null, 2) + "\n");
console.log(`wrote ${schemaOutPath}`);
19 changes: 19 additions & 0 deletions packages/agent-sdk/scripts/schema-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Shared config for ContextBundle JSON Schema generation — used by
* gen-schema.mjs (writes the committed file) and the drift-gate test.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";

const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

export const generatorConfig = {
path: path.join(packageDir, "src/bundle.ts"),
tsconfig: path.join(packageDir, "tsconfig.json"),
type: "ContextBundle",
topRef: true,
additionalProperties: false,
};

/** Committed schema location (repo root — dist/ is gitignored). */
export const schemaOutPath = path.resolve(packageDir, "../../schemas/context-bundle.schema.json");
14 changes: 14 additions & 0 deletions packages/agent-sdk/src/bundle.schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import fs from "node:fs";

import { createGenerator } from "ts-json-schema-generator";
import { describe, expect, it } from "vitest";

import { generatorConfig, schemaOutPath } from "../scripts/schema-config.mjs";

describe("ContextBundle JSON Schema drift gate", () => {
it("committed schema matches the TS types — run `pnpm --filter @coderadar/agent-sdk schema` after changes", () => {
const generated = createGenerator(generatorConfig).createSchema(generatorConfig.type);
const committed: unknown = JSON.parse(fs.readFileSync(schemaOutPath, "utf-8"));
expect(committed).toEqual(JSON.parse(JSON.stringify(generated)));
});
});
100 changes: 100 additions & 0 deletions packages/agent-sdk/src/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ComponentNode, DataSourceNode, LineageEdge, LineageGraph } from "@coderadar/core";
import { nodeId } from "@coderadar/core";
import { describe, expect, it } from "vitest";

import { buildBundle, estimateTokens } from "./bundle.js";

const loc = (f: string) => ({ file: f, line: 1, endLine: 1 });

/** A component "BigCard" that fetches from `count` endpoints — a large lineage. */
function graphWithLineage(count: number): LineageGraph {
const component: ComponentNode = {
id: nodeId("component", "BigCard.tsx", "BigCard"),
kind: "component",
name: "BigCard",
loc: loc("BigCard.tsx"),
exportName: "BigCard",
props: [],
renderedText: [{ text: "Big card overview", source: "jsx" }],
rendersComponents: [],
structure: {
table: 0,
columns: 0,
form: 0,
input: 0,
button: 0,
link: 0,
image: 0,
heading: 0,
list: 0,
repeated: 0,
},
};
const sources: DataSourceNode[] = [];
const edges: LineageEdge[] = [];
for (let i = 0; i < count; i += 1) {
const endpoint = `/api/resource/${i}/details/expanded`;
const id = nodeId("data-source", "BigCard.tsx", `fetch:${endpoint}`);
sources.push({
id,
kind: "data-source",
name: endpoint,
loc: loc("BigCard.tsx"),
sourceKind: "fetch",
method: "GET",
endpoint,
raw: endpoint,
resolved: "full",
});
edges.push({ from: component.id, to: id, kind: "fetches-from" });
}
return {
version: 2,
root: "/app",
generatedAt: "2026-01-01T00:00:00.000Z",
generator: "test",
nodes: [component, ...sources],
edges,
};
}

describe("buildBundle (TRACKER 5.2, F1)", () => {
const graph = graphWithLineage(40);

it("populates match, lineage, and journeys for a matched ticket", () => {
const bundle = buildBundle(graph, { text: "the Big card overview is wrong" }, { budgetTokens: 8000 });
expect(bundle.status).toBe("matched");
expect(bundle.match[0]?.component).toBe("BigCard");
expect(bundle.lineage[0]?.dataSources.length).toBeGreaterThan(0);
expect(bundle.journeys.length).toBeGreaterThan(0);
});

it("stays within budget at 2k / 4k / 8k tokens", () => {
for (const budget of [2000, 4000, 8000]) {
const bundle = buildBundle(graph, { text: "the Big card overview" }, { budgetTokens: budget });
expect(estimateTokens(bundle)).toBeLessThanOrEqual(budget);
expect(bundle.budget.used).toBeLessThanOrEqual(budget);
}
});

it("trims lower-priority sections first, recording each in warnings", () => {
// A budget that fits the match but not the full lineage.
const bundle = buildBundle(graph, { text: "the Big card overview" }, { budgetTokens: 300 });
expect(bundle.match.length).toBeGreaterThan(0); // match is never dropped
expect(estimateTokens(bundle)).toBeLessThanOrEqual(300);
// history/tests/journeys go before lineage.
const trimmed = bundle.warnings.filter((w) => w.includes("trimmed"));
const order = trimmed.map((w) => w.match(/trimmed \d+ (\w+)/)?.[1]);
const idx = (s: string) => order.indexOf(s);
if (idx("journeys") !== -1 && idx("lineage") !== -1) {
expect(idx("journeys")).toBeLessThan(idx("lineage"));
}
});

it("declines out-of-domain and unsupported tickets with a warning, no match", () => {
const ood = buildBundle(graph, { text: "kubernetes deployment crash-looping" });
expect(ood.status).toBe("declined");
expect(ood.match).toEqual([]);
expect(ood.warnings.some((w) => w.includes("out-of-scope"))).toBe(true);
});
});
183 changes: 183 additions & 0 deletions packages/agent-sdk/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* The context bundle (TRACKER step 5.2, failure mode F1): the budgeted,
* priority-trimmed payload an agent consumes. Sections are filled by the
* matching/lineage/journey engines; blastRadius, tests, and history are wired
* in steps 5.3/5.4/5.6 and ship as empty arrays until then.
*
* Budgeter: when the estimated token size exceeds `budgetTokens`, sections are
* emptied in reverse priority (history → tests → journeys → blastRadius →
* lineage; match is never dropped, only reduced to the top candidate), and each
* trim is recorded in `warnings`.
*/
import {
type JourneyPath,
journeys,
type LineageGraph,
traceLineage,
} from "@coderadar/core";

import { resolveContext } from "./resolve.js";
import type { EntryPoint, Ticket } from "./types.js";

export interface BundleMatch {
component: string;
instances: string[];
confidence: "high" | "medium" | "low";
evidence: string[];
}

export interface BundleLineageEntry {
/** Component name, or `Name@file:line` for a specific instance. */
target: string;
dataSources: { method: string | null; endpoint: string }[];
state: string[];
events: string[];
}

/** Reverse-traversal impact (step 5.3) — empty until then. */
export interface BundleImpact {
node: string;
relation: string;
distance: number;
}

/** Test coverage (step 5.4) — empty until then. */
export interface BundleTest {
file: string;
}

/** Recent git history (step 5.6) — empty until then. */
export interface BundleCommit {
sha: string;
subject: string;
}

export interface ContextBundle {
ticket: { text: string; entryPoint: EntryPoint };
status: "matched" | "ambiguous" | "declined";
match: BundleMatch[];
lineage: BundleLineageEntry[];
journeys: JourneyPath[];
blastRadius: BundleImpact[];
tests: BundleTest[];
history: BundleCommit[];
warnings: string[];
budget: { tokens: number; used: number };
}

export interface BundleOptions {
/** Token budget the finished bundle must fit under. Default 4000. */
budgetTokens?: number;
/** Journey expansion depth around the match. Default 2. */
journeyDepth?: number;
}

/** Trim sections in this reverse-priority order until the bundle fits its budget. */
const TRIM_ORDER = ["history", "tests", "journeys", "blastRadius", "lineage"] as const;

/** A deterministic, tokenizer-free size estimate (≈ 4 chars per token). */
export function estimateTokens(bundle: ContextBundle): number {
return Math.ceil(JSON.stringify({ ...bundle, budget: undefined }).length / 4);
}

/** Resolve a ticket into a budgeted context bundle. */
export function buildBundle(
graph: LineageGraph,
ticket: Ticket,
options: BundleOptions = {},
): ContextBundle {
const budgetTokens = options.budgetTokens ?? 4000;
const depth = options.journeyDepth ?? 2;
const ctx = resolveContext(graph, ticket);

const bundle: ContextBundle = {
ticket: { text: ticket.text, entryPoint: ctx.entryPoint },
status: "declined",
match: [],
lineage: [],
journeys: [],
blastRadius: [],
tests: [],
history: [],
warnings: [],
budget: { tokens: budgetTokens, used: 0 },
};

if (ctx.decline !== undefined) {
bundle.warnings.push(`declined (${ctx.decline.reason}): ${ctx.decline.message}`);
return trimToBudget(bundle, budgetTokens);
}
const match = ctx.match;
if (match === undefined || match.status === "declined") {
bundle.warnings.push(`no component matched (${match?.declineReason ?? "no result"})`);
return trimToBudget(bundle, budgetTokens);
}

bundle.status = match.status === "ambiguous" ? "ambiguous" : "matched";
const limit = match.status === "ambiguous" ? 5 : 3;
for (const candidate of match.candidates.slice(0, limit)) {
bundle.match.push({
component: candidate.value.component.name,
instances: candidate.value.instances.map((i) => `${i.loc.file}:${i.loc.line}`),
confidence: candidate.confidence.level,
evidence: candidate.evidence.map((e) => e.detail),
});
}
if (match.status === "ambiguous" && match.disambiguation !== undefined) {
bundle.warnings.push(`ambiguous — ${match.disambiguation}`);
}

const top = match.candidates[0]?.value;
if (top !== undefined) {
const definitionLineage = traceLineage(graph, top.component.id).candidates[0]?.value;
if (definitionLineage !== undefined) {
bundle.lineage.push({
target: top.component.name,
dataSources: definitionLineage.dataSources.map((d) => ({
method: d.method,
endpoint: d.endpoint,
})),
state: definitionLineage.state.map((s) => s.name),
events: definitionLineage.events.map((e) => e.event),
});
}
for (const instance of top.instances) {
const instLineage = traceLineage(graph, instance.id).candidates[0]?.value;
if (instLineage === undefined || instLineage.dataSources.length === 0) continue;
bundle.lineage.push({
target: `${top.component.name}@${instance.loc.file}:${instance.loc.line}`,
dataSources: instLineage.dataSources.map((d) => ({ method: d.method, endpoint: d.endpoint })),
state: [],
events: [],
});
}
bundle.journeys = journeys(graph, top.component.name, { depth }).candidates[0]?.value ?? [];
}

if (graph.meta?.dirty === true) {
bundle.warnings.push("graph built from a dirty working tree — may not match committed code");
}
const incomplete = graph.nodes.filter((n) => n.flags?.includes("incomplete")).length;
if (incomplete > 0) bundle.warnings.push(`${incomplete} node(s) could not be fully parsed`);

return trimToBudget(bundle, budgetTokens);
}

function trimToBudget(bundle: ContextBundle, budgetTokens: number): ContextBundle {
for (const section of TRIM_ORDER) {
if (estimateTokens(bundle) <= budgetTokens) break;
const list = bundle[section];
if (Array.isArray(list) && list.length > 0) {
const n = list.length;
list.length = 0;
bundle.warnings.push(`trimmed ${n} ${section} entr${n === 1 ? "y" : "ies"} to fit the ${budgetTokens}-token budget`);
}
}
if (estimateTokens(bundle) > budgetTokens && bundle.match.length > 1) {
const dropped = bundle.match.length - 1;
bundle.match = bundle.match.slice(0, 1);
bundle.warnings.push(`trimmed ${dropped} lower-ranked match candidate(s) to fit the budget`);
}
bundle.budget.used = estimateTokens(bundle);
return bundle;
}
11 changes: 11 additions & 0 deletions packages/agent-sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
export type { Classification, ContextResult, EntryPoint, Ticket } from "./types.js";
export { classifyTicket } from "./classify.js";
export { extractTerms, resolveContext } from "./resolve.js";
export {
type BundleCommit,
type BundleImpact,
type BundleLineageEntry,
type BundleMatch,
type BundleOptions,
type BundleTest,
buildBundle,
type ContextBundle,
estimateTokens,
} from "./bundle.js";
3 changes: 2 additions & 1 deletion packages/agent-sdk/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
Loading
Loading