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
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,20 @@ map). When adding an agent sub-call:
rather than treating truncated output as a valid empty
result - `completeJsonObjectWithMeta` in
`venice/agents/_curation_helpers.ts` exists for this.
- ALWAYS set `max_completion_tokens` (`maxTokens`) explicitly,
including on `runHeadlessAgent` tool loops. When the field is
absent from the wire body, the serving backend reserves its own
default output budget out of the context window - observed at
65536 tokens - and long inputs then 400 with "maximum context
length exceeded" even though the expected output is tiny.
- Do not treat the registry's `contextWindow`
(src/lib/models/index.ts) as an enforced contract. The ceiling
belongs to whatever backend is serving the model id and it moves:
deepseek-v4-flash enforced 163840 on 2026-07-23 and accepted
1M-scale requests a week later. Code that must fit inside a
window budgets against a pinned conservative constant (see
`WORKING_CONTEXT_TOKENS` in `venice/agents/_accumulator.ts`) and
handles the context-length 400 reactively.

## User-facing documentation

Expand Down
51 changes: 45 additions & 6 deletions docs/dev/wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,19 @@ Edge function (`supabase/functions/venice/`):
and fallback constants, and the per-run tunables: model
`deepseek-v4-flash` (hardcoded mirror of `agentModel('wiki')` -
`AGENT_MODELS` is a static role->model map, not a per-user tier),
fallback `arcee-trinity-large-thinking`, `reasoningEffort:
'medium'`, claim TTL 600s, failure cap 3, sweep bound 3
threads/tick. Test-only invariants exported via `__test`.
fallback `venice-uncensored-1-2`, `reasoningEffort:
'medium'`, output cap 8192 tokens/round, claim TTL 600s, failure
cap 3, sweep bound 3 threads/tick. Test-only invariants exported
via `__test`.
- `agents/_accumulator.ts` - distill-then-act support for oversized
transcripts (port of fnord's accumulator pattern): the pinned
`WORKING_CONTEXT_TOKENS` budget, `transcriptFitsDirect`,
`distillTranscript` (chunked notes accumulation w/ context-length
backoff), `isContextLengthError`, and
`renderDistilledNotesBlock`. Shared by `wiki.ts` and
`wiki_records.ts`; see "Context-window handling" under the sweep
section for the full flow. Unit-tested at
`supabase/functions/tests/accumulator.test.ts`.
- `agents/wiki_manual.ts` - the per-article manual agent (the "Ask
agent to update" flow). Exports `runWikiManualUpdate(adminClient,
userId, { articleId, instructions })`: reads the persisted article
Expand Down Expand Up @@ -748,7 +758,7 @@ conversation.
- `wiki_skip_fallback_attempted boolean not null default false` -
true when the per-thread skip was stamped after the agent already
retried with the uncensored fallback model
(`arcee-trinity-large-thinking` per
(`venice-uncensored-1-2` per
`CONTENT_FILTER_FALLBACK_MODEL` in the edge agent). The
eligibility predicate's OR clause uses this to re-eligibilise
legacy content-classifier skips (rows skipped before the fallback
Expand Down Expand Up @@ -1042,8 +1052,10 @@ the route, which pg_net ignores but the dev shim prints.
**Content-classifier fallback (in `runWikiAgentOnThread`).** Before
the per-thread failure counter ever increments for a classifier
rejection, the agent itself retries the tool loop once against
`CONTENT_FILTER_FALLBACK_MODEL` (`arcee-trinity-large-thinking`,
which does not run the same input classifier). Only the
`CONTENT_FILTER_FALLBACK_MODEL` (`venice-uncensored-1-2`,
which does not run the same input classifier; it must support
function calling and is a non-reasoning model, so the fallback
attempt keeps `reasoning_effort` off the wire). Only the
content-filter sentinel (`"Input text data may contain inappropriate
content"`, matched as a substring of the error message) triggers the
retry; any other error (network blip, 500, parse failure) returns
Expand All @@ -1056,6 +1068,33 @@ and when the counter eventually advances the pointer,
so the eligibility predicate's OR clause can't loop the same thread
back into the queue.

**Context-window handling (distill-then-act, `_accumulator.ts`).**
Both per-thread agents (article + record extraction) bound what they
send to Venice. Every tool-loop round carries an explicit
`max_completion_tokens` (8192); without it, the serving backend
reserves its own default output budget - observed at 65536 tokens -
out of the context window, which on 2026-07-23 starved two long
threads of input room and skipped them ("maximum context length is
163840 tokens"). The registry's `contextWindow` for the wiki models
says 1M, but the enforced ceiling demonstrably moves between
backends serving the same model id, so the agents budget against the
conservative `WORKING_CONTEXT_TOKENS` (96k) in
`agents/_accumulator.ts` instead. A transcript estimated over that
window is **distilled before the tool loop**: the accumulator
renders the slice to text (tool traffic excerpted), chunks it, and
folds each chunk into an accumulated notes buffer via one completion
per chunk - fnord's accumulator pattern - then the normal tool loop
runs once over the notes instead of the raw transcript, so all
writes still happen with the full toolbox and dedup discipline
(chunk passes are read-only by design). A context-length 400 from a
direct run triggers the same distill path reactively; a 400 during
distillation backs off the chunk budget (0.2 steps, 0.6 floor)
before giving up. A context-length error that survives all of that
is marked `deterministic` on the run outcome and the sweep skips the
thread on the FIRST failure (`p_max_failures = 1`) with the honest
reason, instead of burning the transient 3-strike budget on an error
that repeats identically.

This differs from the journal flow, which uses an atomic
`upsert_journal_entry_and_mark_thread` RPC because the entry write
and the pointer advance must happen in lockstep. The wiki agent's
Expand Down
2 changes: 1 addition & 1 deletion docs/user/wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ Sometimes the agent can't process a conversation. The most common
reason is Venice's content classifier rejecting the conversation
body as inappropriate. When that happens, the agent automatically
retries the run against an **uncensored fallback model** (currently
`arcee-trinity-large-thinking`) which doesn't run the same
`venice-uncensored-1-2`) which doesn't run the same
classifier, so most conversations the default model balks at get
processed transparently on the retry. You don't have to do
anything; it just works on the next sweep.
Expand Down
224 changes: 224 additions & 0 deletions supabase/functions/tests/accumulator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// Unit coverage for the distill-then-act accumulator
// (venice/agents/_accumulator.ts): the token estimate + fits-direct
// gate, the chunker's whole-block / hard-split behavior, message
// rendering (tool-traffic excerpting), the distill loop's buffer
// threading, the context-length backoff, and the empty-completion
// guard. All offline - the completion seam is scripted, no network.

import { assertEquals, assertRejects, assertStringIncludes } from '@std/assert';
import {
__test,
distillTranscript,
estimateWireTokens,
isContextLengthError,
renderDistilledNotesBlock,
transcriptFitsDirect,
WORKING_CONTEXT_TOKENS,
} from '../venice/agents/_accumulator.ts';
import type { VeniceWireMessage } from '../venice/agents/_recall_helpers.ts';
import type { ToolCompletionResult } from '../venice/tools/_venice_complete.ts';

const { nextChunk, renderMessage, TOOL_RESULT_EXCERPT_CHARS } = __test;

function completion(partial: Partial<ToolCompletionResult>): ToolCompletionResult {
return {
text: '',
reasoning: '',
citations: [],
finishReason: 'stop',
usage: null,
toolCalls: [],
...partial,
};
}

const CTX_ERROR = new Error(
"Venice chat/completions 400: {\"error\":{\"message\":\"This model's maximum context length is 163840 tokens.",
);

Deno.test('isContextLengthError matches the upstream sentinel and nothing else', () => {
assertEquals(isContextLengthError(CTX_ERROR), true);
assertEquals(isContextLengthError(new Error('Venice chat/completions 500: gateway error')), false);
assertEquals(
isContextLengthError(
new Error('Venice chat/completions 400: {"error":"Input text data may contain inappropriate content."}'),
),
false,
);
assertEquals(isContextLengthError('maximum context length'), false); // not an Error
assertEquals(isContextLengthError(null), false);
});

Deno.test('estimateWireTokens counts content plus tool_calls JSON', () => {
const messages: VeniceWireMessage[] = [
{ role: 'user', content: 'a'.repeat(400) },
{
role: 'assistant',
content: '',
tool_calls: [{ id: '1', type: 'function', function: { name: 'x', arguments: '{}' } }],
},
];
const tokens = estimateWireTokens(messages);
// 400 chars of content plus the tool_calls JSON, at 4 chars/token.
const toolJson = JSON.stringify(messages[1].tool_calls).length;
assertEquals(tokens, Math.ceil((400 + toolJson) / 4));
});

Deno.test('transcriptFitsDirect gates on the working context window', () => {
const small: VeniceWireMessage[] = [{ role: 'user', content: 'hi' }];
assertEquals(transcriptFitsDirect(small), true);
const big: VeniceWireMessage[] = [
{ role: 'user', content: 'x'.repeat((WORKING_CONTEXT_TOKENS + 1) * 4) },
];
assertEquals(transcriptFitsDirect(big), false);
});

Deno.test('nextChunk takes whole blocks up to the budget', () => {
const { chunk, consumed, remainder } = nextChunk(['aaaa', 'bbbb', 'cccc'], 10);
// 4 + 1 + 4 + 1 = 10 fits two blocks; the third would overflow.
assertEquals(consumed, 2);
assertEquals(chunk, 'aaaa\nbbbb');
assertEquals(remainder, null);
});

Deno.test('nextChunk hard-splits a single oversized block and returns the tail', () => {
const { chunk, consumed, remainder } = nextChunk(['x'.repeat(25), 'next'], 10);
assertEquals(consumed, 1);
assertEquals(chunk, 'x'.repeat(10));
assertEquals(remainder, 'x'.repeat(15));
});

Deno.test('renderMessage keeps prose whole and excerpts tool traffic', () => {
const prose = renderMessage({ role: 'user', content: 'tell me about my dog' });
assertEquals(prose, '[user]\ntell me about my dog');

const bigResult = renderMessage({
role: 'tool',
content: 'r'.repeat(TOOL_RESULT_EXCERPT_CHARS + 500),
name: 'web_search',
});
assertStringIncludes(bigResult, '[tool result (web_search)]');
assertStringIncludes(bigResult, '500 more chars omitted');

const withCalls = renderMessage({
role: 'assistant',
content: 'checking',
tool_calls: [
{ id: '1', type: 'function', function: { name: 'wiki_search', arguments: '{"query":"dog"}' } },
],
});
assertStringIncludes(withCalls, '(called wiki_search with {"query":"dog"})');
});

Deno.test('distillTranscript threads the notes buffer across chunks', async () => {
// Two blocks sized so each fills most of a chunk budget - forces two
// distill passes with the first pass's notes visible to the second.
const blockChars = (WORKING_CONTEXT_TOKENS - 20_000) * 4;
const messages: VeniceWireMessage[] = [
{ role: 'user', content: 'a'.repeat(blockChars) },
{ role: 'user', content: 'b'.repeat(blockChars) },
];
const seenNotes: string[] = [];
let pass = 0;
const notes = await distillTranscript({
apiKey: 'k',
model: 'm',
messages,
focus: 'FOCUS-MARKER',
// deno-lint-ignore require-await
complete: async (opts) => {
pass += 1;
const system = String(opts.messages[0].content);
assertStringIncludes(system, 'FOCUS-MARKER');
const user = String(opts.messages[1].content);
seenNotes.push(user.split('# Next transcript chunk')[0]);
return completion({ text: `NOTES-${pass}` });
},
});
assertEquals(pass, 2);
assertEquals(notes, 'NOTES-2');
assertStringIncludes(seenNotes[0], '(none yet - this is the first chunk)');
// Pass 2 builds on pass 1's notes, not on a reset buffer.
assertStringIncludes(seenNotes[1], 'NOTES-1');
});

Deno.test('distillTranscript backs off the chunk budget on a context-length rejection', async () => {
const messages: VeniceWireMessage[] = [
{ role: 'user', content: 'z'.repeat(WORKING_CONTEXT_TOKENS * 4 * 2) },
];
const chunkSizes: number[] = [];
let first = true;
const notes = await distillTranscript({
apiKey: 'k',
model: 'm',
messages,
focus: 'f',
// deno-lint-ignore require-await
complete: async (opts) => {
const user = String(opts.messages[1].content);
chunkSizes.push(user.length);
if (first) {
first = false;
throw CTX_ERROR;
}
return completion({ text: 'ok' });
},
});
assertEquals(notes, 'ok');
// The retry after the rejection sent a strictly smaller chunk.
assertEquals(chunkSizes.length >= 2, true);
assertEquals(chunkSizes[1] < chunkSizes[0], true);
});

Deno.test('distillTranscript gives up when backoff hits the floor', async () => {
const messages: VeniceWireMessage[] = [
{ role: 'user', content: 'z'.repeat(WORKING_CONTEXT_TOKENS * 4) },
];
let calls = 0;
await assertRejects(
() =>
distillTranscript({
apiKey: 'k',
model: 'm',
messages,
focus: 'f',
// deno-lint-ignore require-await
complete: async () => {
calls += 1;
throw CTX_ERROR;
},
}),
Error,
'maximum context length',
);
// Fractions tried: 1.0, 0.8, 0.6 - then 0.4 would cross the floor,
// so the third rejection surfaces.
assertEquals(calls, 3);
});

Deno.test('distillTranscript rejects an empty distill pass instead of wiping the notes', async () => {
const messages: VeniceWireMessage[] = [{ role: 'user', content: 'hello' }];
await assertRejects(
() =>
distillTranscript({
apiKey: 'k',
model: 'm',
messages,
focus: 'f',
// A truncated reasoning pass: the whole output budget went to
// thinking and content came back empty.
// deno-lint-ignore require-await
complete: async () => completion({ text: '', finishReason: 'length' }),
}),
Error,
'finish_reason=length',
);
});

Deno.test('renderDistilledNotesBlock wraps the notes and names them a distillation', () => {
const block = renderDistilledNotesBlock('THE NOTES');
assertStringIncludes(block, '<conversation_notes>');
assertStringIncludes(block, 'THE NOTES');
assertStringIncludes(block, 'too large to include verbatim');
assertStringIncludes(block, '</conversation_notes>');
});
Loading