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
15 changes: 9 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,16 @@ ALLOW_UNVERIFIED_EMAILS=1
# HUB_SIDECAR_WEBSOCKET_URL=

# SIDECAR_ADAPTER_MANIFEST configures custom Interchange inference adapters
# for a sidecar process, overriding built-in adapters that share a provider
# key (@intx/inference's loadAdapterRegistry). Leave unset (the default) to
# run the built-ins only. The value is a JSON array of
# for a sidecar process, replacing the default manifest wholesale (not
# merging with it). Leave unset (the default) and the sidecar already
# registers @corbits/ollama-adapter for the "ollama" provider key, so a
# seeded Ollama model's per-model num_ctx reaches Ollama with no operator
# configuration. Set this only to point a provider key at a different
# adapter package. The value is a JSON array of
# {"provider","specifier","export"} entries; each specifier must resolve
# from the sidecar's own module-resolution root (an installed package, not
# a bare file path), and every workflow-process child it spawns resolves
# the same manifest. Example activating @corbits/ollama-adapter for the
# "ollama" provider key:
# SIDECAR_ADAPTER_MANIFEST=[{"provider":"ollama","specifier":"@corbits/ollama-adapter","export":"createOllamaAdapter"}]
# the same manifest. Example replacing the default with a custom adapter
# for the "anthropic" provider key:
# SIDECAR_ADAPTER_MANIFEST=[{"provider":"anthropic","specifier":"@acme/custom-anthropic-adapter","export":"createCustomAdapter"}]

1 change: 1 addition & 0 deletions apps/sidecar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dependencies": {
"@corbits/agent-lifecycle": "workspace:*",
"@corbits/credential-providers": "workspace:*",
"@corbits/ollama-adapter": "workspace:*",
"@corbits/workflow-host-actions": "workspace:*",
"@intx/agent": "0.3.0",
"@intx/authz": "0.3.0",
Expand Down
51 changes: 37 additions & 14 deletions apps/sidecar/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ import { AdapterManifest } from "@intx/inference";

import { parseToolRegistries } from "./tool-materialization";

/**
* The shipped default manifest: registers `@corbits/ollama-adapter`'s
* `createOllamaAdapter` for the `"ollama"` provider key so a seeded
* Ollama deployment's `quirks.numCtx` (see `@corbits/hub-client`'s
* seed and `./workflow-substrate-factory/context-budget`) actually
* reaches Ollama's `options.num_ctx` instead of silently falling back
* to the built-in adapter's defaults. An operator who sets
* `SIDECAR_ADAPTER_MANIFEST` explicitly gets exactly what they wrote --
* this default never merges with an operator value, only replaces the
* unset case.
*/
const DEFAULT_ADAPTER_MANIFEST: AdapterManifest = [
{
provider: "ollama",
specifier: "@corbits/ollama-adapter",
export: "createOllamaAdapter",
},
];

const WsURL = type("string").narrow((url, ctx) => {
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
return ctx.mustBe("a ws:// or wss:// URL");
Expand Down Expand Up @@ -40,14 +59,15 @@ const SidecarEnv = type({
// workflow-process child's spawn env so per-step tool
// materialization resolves the exact registries the operator pinned.
"SIDECAR_TOOL_REGISTRIES?": "string",
// Optional JSON-encoded custom inference adapter manifest
// Optional JSON-encoded custom inference adapter manifest override
// (`AdapterManifestEntry[]`, `[{"provider","specifier","export"}]`).
// Unset means no custom adapters -- `loadAdapterRegistry` resolves the
// built-ins only. Validated here so a malformed manifest kills the boot
// with the variable named, and threaded (as its parsed form) into both
// this process's own adapter registry and every workflow-process
// child's `SIDECAR_ADAPTER_MANIFEST` substrate-config entry, so a child
// resolves the exact custom adapters this boot edge resolved.
// Unset resolves to `DEFAULT_ADAPTER_MANIFEST` (the shipped Ollama
// adapter); set, it replaces that default entirely rather than merging
// with it. Validated here so a malformed manifest kills the boot with
// the variable named, and threaded (as its parsed form) into both this
// process's own adapter registry and every workflow-process child's
// `SIDECAR_ADAPTER_MANIFEST` substrate-config entry, so a child
// resolves the exact adapters this boot edge resolved.
"SIDECAR_ADAPTER_MANIFEST?": "string",
// Operator overrides for two workflow-supervisor timing bindings,
// threaded verbatim to every deployment's supervisor
Expand Down Expand Up @@ -95,9 +115,10 @@ export type SidecarConfig = {
*/
readonly toolRegistries: string | undefined;
/**
* The operator's custom inference adapter manifest, already validated
* against {@link AdapterManifest}. Empty when the operator configured
* none -- `loadAdapterRegistry([])` then resolves the built-ins only.
* The inference adapter manifest, already validated against
* {@link AdapterManifest}: {@link DEFAULT_ADAPTER_MANIFEST} unless the
* operator set `SIDECAR_ADAPTER_MANIFEST`, in which case it is exactly
* (and only) what the operator wrote.
*/
readonly adapterManifest: AdapterManifest;
/**
Expand All @@ -117,14 +138,16 @@ export type SidecarConfig = {

/**
* Parse the optional `SIDECAR_ADAPTER_MANIFEST` env value into a validated
* {@link AdapterManifest}. Unset resolves to `[]` (no custom adapters);
* a malformed value dies at boot with the variable named, rather than
* surfacing as a deep-stack `loadAdapterRegistry` import failure.
* {@link AdapterManifest}. Unset resolves to {@link DEFAULT_ADAPTER_MANIFEST}
* (the shipped Ollama adapter, so `num_ctx` reaches Ollama without operator
* configuration); a malformed value dies at boot with the variable named,
* rather than surfacing as a deep-stack `loadAdapterRegistry` import
* failure.
*/
export function parseSidecarAdapterManifest(
raw: string | undefined,
): AdapterManifest {
if (raw === undefined) return [];
if (raw === undefined) return DEFAULT_ADAPTER_MANIFEST;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
Expand Down
75 changes: 75 additions & 0 deletions apps/sidecar/src/workflow-substrate-factory/compactors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,81 @@ test("createBudgetedContextCompactor: a conversation past the budget is folded r
expect(summary?.role).toBe("system");
});

test("estimateTurnsChars measures a tool_result's real content size, not a placeholder", () => {
const largeResult = "x".repeat(20_000);
const turn: ConversationTurn = {
role: "user",
timestamp: 0,
content: [
{
type: "tool_result",
callId: "call_1",
content: [{ type: "text", text: largeResult }],
},
],
};

// The old estimator measured `excerptBlock`'s placeholder
// (`[tool_result call_1]`, ~20 chars) instead of the real payload.
expect(estimateTurnsChars([turn])).toBeGreaterThanOrEqual(20_000);
});

test("estimateTurnsChars measures a tool_call's real argument size, not a placeholder", () => {
const largeArgs = { query: "y".repeat(15_000) };
const turn: ConversationTurn = {
role: "assistant",
timestamp: 0,
content: [
{ type: "tool_call", id: "c1", name: "search", arguments: largeArgs },
],
};

expect(estimateTurnsChars([turn])).toBeGreaterThanOrEqual(15_000);
});

test("estimateTurnsChars: ten turns each carrying a 20,000-char tool_result sum to their real size, not 10 placeholders", () => {
const turns: ConversationTurn[] = Array.from({ length: 10 }, (_, i) => ({
role: "user" as const,
timestamp: i,
content: [
{
type: "tool_result" as const,
callId: `call_${String(i)}`,
content: [{ type: "text" as const, text: "z".repeat(20_000) }],
},
],
}));

const chars = estimateTurnsChars(turns);

// A hard limit sized for real conversations (e.g. 32,000 chars) must
// see this as over budget -- the old placeholder-based estimator
// returned ~160 chars for the same turns and let it through silently.
expect(chars).toBeGreaterThan(32_000);
expect(chars).toBeGreaterThanOrEqual(200_000);
});

test("createBudgetedContextCompactor: folded output never exceeds the budget it folded to", async () => {
// Realistic scale (comparable to `resolveContextBudgetChars` at a
// small `numCtx`, e.g. ~4900 chars for numCtx=2048): a budget bigger
// than `maxSummaryChars` (4000) but not by much, the exact regime
// where an uncounted summary previously pushed the total over.
const turns = Array.from({ length: 40 }, (_, i) =>
textTurn(
i % 2 === 0 ? "user" : "assistant",
`message number ${i} `.repeat(25),
i,
),
);
const budgetChars = 6_000;
const compactor = createBudgetedContextCompactor(budgetChars);

const result = await compactor.apply(turns, makeCtx());

expect(result.record.reason).toBe("folded-older-turns");
expect(estimateTurnsChars(result.output)).toBeLessThanOrEqual(budgetChars);
});

test("createBudgetedContextCompactor: always keeps a minimum verbatim tail even under a near-zero budget", async () => {
const turns = Array.from({ length: 10 }, (_, i) =>
textTurn(i % 2 === 0 ? "user" : "assistant", `message ${i}`, i),
Expand Down
96 changes: 93 additions & 3 deletions apps/sidecar/src/workflow-substrate-factory/compactors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,91 @@ const MIN_KEPT_TURNS = 4;
function turnChars(turn: ConversationTurn): number {
let total = 0;
for (const block of turn.content) {
total += excerptBlock(block).length;
total += blockPayloadChars(block);
}
return total;
}

/**
* A media block's real payload size: base64 data / a URL / a file
* reference, whichever the source carries. Shared by top-level media
* blocks and the media items nested in a `tool_result`'s `content`.
*/
function mediaSourceChars(source: {
kind: "base64" | "file-reference" | "url";
data?: string;
url?: string;
reference?: string;
}): number {
switch (source.kind) {
case "base64":
return source.data?.length ?? 0;
case "url":
return source.url?.length ?? 0;
case "file-reference":
return source.reference?.length ?? 0;
}
}

/**
* A `tool_result` content item's real size: text length, or the
* underlying media source's size for every other item kind.
*/
function toolResultItemChars(
item: Extract<ContentBlock, { type: "tool_result" }>["content"][number],
): number {
return item.type === "text"
? item.text.length
: mediaSourceChars(item.source);
}

/**
* A block's true payload size -- what actually ships to the model --
* as distinct from {@link excerptBlock}'s human-readable placeholder.
* `tool_call.arguments` and `tool_result.content` carry real request/
* response payloads that can dwarf the rest of a turn; a budget
* estimator blind to them silently undercounts by orders of magnitude.
*/
function blockPayloadChars(block: ContentBlock): number {
switch (block.type) {
case "text":
return block.text.length;
case "refusal":
return block.reason.length;
case "thinking":
return block.thinking.length;
case "redacted_thinking":
return block.data.length;
case "citation":
return block.citedText.length;
case "safety_rating":
return block.blockReason.length;
case "code_execution_request":
return block.code.length;
case "code_execution_result":
return (block.stdout?.length ?? 0) + (block.stderr?.length ?? 0);
case "image":
case "audio":
case "video":
case "document":
return mediaSourceChars(block.source);
case "tool_call":
return block.name.length + JSON.stringify(block.arguments).length;
case "tool_result": {
let total = 0;
for (const item of block.content) {
total += toolResultItemChars(item);
}
if (block.detail !== undefined) {
total += JSON.stringify(block.detail).length;
}
return total;
}
default:
return 0;
}
}

/** Total character length of a turn list -- the budget check's estimate. */
export function estimateTurnsChars(turns: ConversationTurn[]): number {
let total = 0;
Expand Down Expand Up @@ -279,8 +359,11 @@ export function createBudgetedContextCompactor(
turns: ConversationTurn[],
_ctx: StrategyContext,
): Promise<StrategyResult<ConversationTurn[]>> {
const keep = countTurnsWithinBudget(turns, budgetChars);
if (keep >= turns.length) {
// First check against the full budget, exactly as when no fold is
// needed at all -- a conversation already under budget must stay
// untouched rather than being folded pre-emptively to make room
// for a summary turn nothing will produce.
if (countTurnsWithinBudget(turns, budgetChars) >= turns.length) {
return {
output: turns,
record: {
Expand All @@ -296,6 +379,13 @@ export function createBudgetedContextCompactor(
},
};
}

// Folding does happen: reserve the summary turn's own worst-case
// size out of the budget so kept-turns chars + summary chars
// together stay within `budgetChars`, instead of the summary
// landing on top of an already-full budget.
const keepBudgetChars = Math.max(0, budgetChars - maxSummaryChars);
const keep = countTurnsWithinBudget(turns, keepBudgetChars);
return foldOlderTurns(turns, keep, {
strategy: SUMMARIZE_BUDGETED_TURNS_NAME,
version: SUMMARIZE_BUDGETED_TURNS_VERSION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
WORKBENCH_DIRECTOR_ID,
createWorkbenchDirector,
createWorkbenchDirectorRegistry,
workbenchDirectorFactory,
} from "./workbench-director";

const caps = createCapabilities();
Expand Down Expand Up @@ -86,6 +85,20 @@ function conversationTurn(text: string): ConversationTurn {
return { role: "user", content: [{ type: "text", text }], timestamp: 0 };
}

function toolResultTurn(payload: string, callId: string): ConversationTurn {
return {
role: "user",
content: [
{
type: "tool_result",
callId,
content: [{ type: "text", text: payload }],
},
],
timestamp: 0,
};
}

function stateWithTurns(turns: ConversationTurn[]): ReactorState {
return { ...state(), turns };
}
Expand Down Expand Up @@ -235,6 +248,37 @@ test("context budget: a short conversation under budget is untouched (infers nor
expect(typesOf(actions)).toEqual(["infer"]);
});

test("context budget: tool-heavy history past the hard limit is caught even though every turn's text excerpt is short", async () => {
// The reviewer's exact repro: 10 turns each carrying a 20,000-char
// tool_result. Measured by placeholder length this was ~160 chars
// total (invisible to a 32,000-char hard limit); measured by real
// payload size it is 200,000 chars, well past it.
const director = createWorkbenchDirector(
"you are a test agent",
[],
{},
{
budgetChars: 16_000,
hardLimitChars: 32_000,
compactorName: "summarize-budgeted-turns",
},
);
const bigState = stateWithTurns(
Array.from({ length: 10 }, (_, i) =>
toolResultTurn("x".repeat(20_000), `call_${String(i)}`),
),
);

const actions = await director.decide(
{ type: "message.received", message: { id: "m1", content: "hi" } as never },
bigState,
caps,
);

expect(typesOf(actions)).toEqual(["checkpoint", "reply"]);
expect(replyOf(actions)).toBe(CONTEXT_OVERFLOW_MESSAGE);
});

test("context budget: history past the hard limit replies with the honest overflow message instead of inferring", async () => {
const director = createWorkbenchDirector(
"you are a test agent",
Expand Down Expand Up @@ -326,7 +370,6 @@ test("context budget: with no contextBudget configured, behavior is unchanged",
});

test("the factory is namespaced and is the sidecar registry default", () => {
expect(workbenchDirectorFactory.id).toBe(WORKBENCH_DIRECTOR_ID);
const registry = createWorkbenchDirectorRegistry();
expect(registry.defaultFactory().id).toBe(WORKBENCH_DIRECTOR_ID);
expect(
Expand Down
Loading
Loading