osborn 0.9.226 → 0.9.228
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-llm.d.ts +2 -21
- package/dist/claude-llm.js +70 -399
- package/dist/embedder.d.ts +1 -1
- package/dist/embedder.js +37 -14
- package/dist/prompts/direct-mode-research.md +6 -4
- package/dist/session-store.d.ts +3 -1
- package/dist/session-store.js +65 -20
- package/package.json +1 -1
package/dist/claude-llm.d.ts
CHANGED
|
@@ -121,8 +121,8 @@ export declare function decideWrite(agentType: string | null, filePath: string):
|
|
|
121
121
|
reason?: string;
|
|
122
122
|
};
|
|
123
123
|
export declare const VERIFIER_CHAIN: Record<string, {
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
parallelAtStart?: string[];
|
|
125
|
+
afterReminder?: string;
|
|
126
126
|
}>;
|
|
127
127
|
/**
|
|
128
128
|
* Claude LLM - Wraps Claude Agent SDK for LiveKit
|
|
@@ -297,25 +297,6 @@ export declare class ClaudeLLM extends llm.LLM {
|
|
|
297
297
|
onCheckpoint: (checkpointId: string) => void;
|
|
298
298
|
eventEmitter: EventEmitter;
|
|
299
299
|
}): void;
|
|
300
|
-
/**
|
|
301
|
-
* Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
|
|
302
|
-
* Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
|
|
303
|
-
* if the verdict is REJECT. A reviewer failure must never crash the consumer.
|
|
304
|
-
* Public so ClaudeLLMStream can call it via this.#llmRef.spawnReviewer().
|
|
305
|
-
*/
|
|
306
|
-
spawnReviewer(agentId: string, writerOutput: string, emitter: EventEmitter): Promise<void>;
|
|
307
|
-
/**
|
|
308
|
-
* Dispatcher v1 — auto-spawn a tester after a writer sub-agent completes,
|
|
309
|
-
* in parallel with spawnReviewer. Runs tests against the diff and emits
|
|
310
|
-
* dispatch_rejected with verdict 'TEST-FAIL' if they fail.
|
|
311
|
-
*/
|
|
312
|
-
spawnTester(agentId: string, _writerOutput: string, emitter: EventEmitter): Promise<void>;
|
|
313
|
-
/**
|
|
314
|
-
* Dispatcher v1 — research gate: vet a researcher sub-agent's output before
|
|
315
|
-
* it reaches the main agent. Emits dispatch_rejected with verdict 'NEEDS-MORE'
|
|
316
|
-
* (distinct from reviewer's 'REJECT') so the frontend can tell them apart.
|
|
317
|
-
*/
|
|
318
|
-
spawnResearchGate(agentId: string, researchOutput: string, emitter: EventEmitter): Promise<void>;
|
|
319
300
|
chat({ chatCtx, toolCtx, connOptions, abortController, }: {
|
|
320
301
|
chatCtx: llm.ChatContext;
|
|
321
302
|
toolCtx?: llm.ToolContext;
|
package/dist/claude-llm.js
CHANGED
|
@@ -12,7 +12,6 @@ import { EventEmitter } from 'events';
|
|
|
12
12
|
import { saveSessionMetadata, getSessionWorkspace } from './config.js';
|
|
13
13
|
import { statusManager } from './status-manager.js';
|
|
14
14
|
import { getResearchSystemPrompt, getDirectModeResearchPrompt, getGroundingBlock, getRecalledContextBlock } from './prompts.js';
|
|
15
|
-
import { getIndexPath } from './summary-index.js';
|
|
16
15
|
import { openStore, recall, storeExists, updateSessionStore, getStorePath } from './session-store.js';
|
|
17
16
|
import { getEmbedder } from './embedder.js';
|
|
18
17
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
@@ -39,15 +38,10 @@ const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-rem
|
|
|
39
38
|
const RECALL_TOP_K = 5;
|
|
40
39
|
const RECALL_PER_HIT_CHARS = 800;
|
|
41
40
|
const RECALL_ENABLED = () => process.env.OSBORN_RECALL_INJECT !== '0';
|
|
42
|
-
//
|
|
43
|
-
// the
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (__embedderWarmed || process.env.OSBORN_EMBED === '0')
|
|
47
|
-
return;
|
|
48
|
-
__embedderWarmed = true;
|
|
49
|
-
getEmbedder().catch(() => { });
|
|
50
|
-
}
|
|
41
|
+
// Query-side embed budget. Vector/hybrid is the PREFERRED recall path; keyword is only
|
|
42
|
+
// the fallback. This tight timeout means a slow network degrades to keyword FAST rather
|
|
43
|
+
// than stalling the turn — the read path never waits the full write-path (20s) budget.
|
|
44
|
+
const QUERY_EMBED_TIMEOUT_MS = Number(process.env.OSBORN_QUERY_EMBED_TIMEOUT_MS || 2500);
|
|
51
45
|
/** Build the recalled-context block to inject alongside the turn-shape reminder. '' on any miss. */
|
|
52
46
|
async function buildRecallInjection(sessionId, workingDir, prompt) {
|
|
53
47
|
try {
|
|
@@ -58,9 +52,11 @@ async function buildRecallInjection(sessionId, workingDir, prompt) {
|
|
|
58
52
|
return '';
|
|
59
53
|
if (!storeExists(sessionId, workingDir))
|
|
60
54
|
return '';
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
55
|
+
// Vector/hybrid is the PREFERRED recall path; keyword is only the fallback when the
|
|
56
|
+
// embedder is unavailable or the query embed exceeds its short budget. Acquire the
|
|
57
|
+
// embedder every turn with a tight query timeout — no warm-up needed (it's a cloud
|
|
58
|
+
// fetch, not a local model load). If embed is null, recall() runs keyword-only.
|
|
59
|
+
const embed = process.env.OSBORN_EMBED !== '0' ? (await getEmbedder(QUERY_EMBED_TIMEOUT_MS)) ?? undefined : undefined;
|
|
64
60
|
const { getStorePath } = await import('./session-store.js');
|
|
65
61
|
const db = openStore(getStorePath(sessionId, workingDir));
|
|
66
62
|
let hits;
|
|
@@ -443,7 +439,7 @@ export const NAMED_AGENTS = {
|
|
|
443
439
|
'',
|
|
444
440
|
'## Research-review gate',
|
|
445
441
|
'When reviewing a researcher\'s findings, judge whether the research is COMPLETE and well-sourced against the original task. If it is, pass it. If it\'s thin, missing sources, or the answer likely lives somewhere the researcher didn\'t look, report that it needs more — so the orchestrator sends the researcher back.',
|
|
446
|
-
'
|
|
442
|
+
'Before you finalize, re-check your gate call ONCE: is the research genuinely complete and well-sourced for the original question — not passing thin work, nor failing solid work? Then end every research review with exactly one of:',
|
|
447
443
|
' GATE: PASS',
|
|
448
444
|
' GATE: NEEDS-MORE — <what\'s missing / where to look>',
|
|
449
445
|
'',
|
|
@@ -512,8 +508,8 @@ export const NAMED_AGENTS = {
|
|
|
512
508
|
'## When to use / handoff',
|
|
513
509
|
'Invoked AFTER the planner produces a written plan — the writer is the SOLE agent that edits files.',
|
|
514
510
|
'Do not invoke writer until a plan exists for any multi-step change.',
|
|
515
|
-
'
|
|
516
|
-
'Because that verification runs
|
|
511
|
+
'A tester runs IN PARALLEL with you — it writes tests from the spec, blind to your implementation (TDD). After you return, the orchestrator RUNS those tests against your code and dispatches the reviewer; on test-fail or REJECT you may be sent back to fix (bounded rounds).',
|
|
512
|
+
'Because that verification runs after you finish, make the change review-ready and leave the tree in a runnable state — write the code so the independently-written tests can pass; do not skip cleanup expecting a second pass.',
|
|
517
513
|
].join('\n'),
|
|
518
514
|
},
|
|
519
515
|
tester: {
|
|
@@ -572,6 +568,7 @@ export const NAMED_AGENTS = {
|
|
|
572
568
|
'7. Cap yourself at 6-8 tool calls unless the investigation clearly requires more.',
|
|
573
569
|
'',
|
|
574
570
|
'## What to return',
|
|
571
|
+
'Before you finalize, re-check your conclusion ONCE: did you actually RUN the commands (not assume the outcome), does your result match the real output, and did you distinguish a genuine regression from a flaky/environment failure? Then report:',
|
|
575
572
|
'- RESULT: PASS or FAIL (one word, first line)',
|
|
576
573
|
'- COMMAND: the exact command(s) you ran',
|
|
577
574
|
'- OUTPUT: relevant excerpt (errors, failing test names, line numbers)',
|
|
@@ -748,7 +745,7 @@ export const NAMED_AGENTS = {
|
|
|
748
745
|
' NIT — style, naming, or polish; never a reason to REJECT on its own',
|
|
749
746
|
'',
|
|
750
747
|
'## What to return',
|
|
751
|
-
'
|
|
748
|
+
'Before you finalize, re-check your verdict ONCE: is each BLOCKER/MAJOR real and reproducible (not a style nit or false positive), and does your ACCEPT/REJECT match the severity of what you actually found? Then structure your response EXACTLY as follows:',
|
|
752
749
|
'',
|
|
753
750
|
'VERDICT: ACCEPT | REJECT — <one-line rationale>',
|
|
754
751
|
'',
|
|
@@ -878,14 +875,31 @@ export function decideWrite(agentType, filePath) {
|
|
|
878
875
|
}
|
|
879
876
|
return { decision: 'defer' };
|
|
880
877
|
}
|
|
881
|
-
// ── Verifier chaining
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
878
|
+
// ── Verifier chaining (Pattern #1) ──────────────────────────────────────────
|
|
879
|
+
// Verifiers are real named Task sub-agents (the `agents` roster), NOT out-of-band
|
|
880
|
+
// query() spawns. Two enforcement points:
|
|
881
|
+
// • parallelAtStart — verifiers the ORCHESTRATOR must dispatch IN PARALLEL with
|
|
882
|
+
// the keyed agent (enforced by the orchestrator prompt; a post-hoc hook cannot
|
|
883
|
+
// make two Tasks run concurrently). Tester is TDD: it WRITES tests from the
|
|
884
|
+
// spec in parallel with the writer, blind to the implementation.
|
|
885
|
+
// • afterReminder — injected into the MAIN agent by the PostToolUse-on-Task hook
|
|
886
|
+
// when the keyed sub-agent's Task completes, so the main agent dispatches the
|
|
887
|
+
// next verifier(s) as real Tasks (verdicts return inline). Includes RUNNING the
|
|
888
|
+
// tester's tests against the finished code. Any agent not listed chains to nothing.
|
|
886
889
|
export const VERIFIER_CHAIN = {
|
|
887
|
-
writer: {
|
|
888
|
-
|
|
890
|
+
writer: {
|
|
891
|
+
parallelAtStart: ['tester'],
|
|
892
|
+
afterReminder: 'The writer sub-agent just finished. Before you finalize, you MUST now use the Task tool to: ' +
|
|
893
|
+
"(1) RUN the tester's tests against the new implementation and report PASS/FAIL; " +
|
|
894
|
+
'(2) dispatch the `reviewer` on the diff for a VERDICT. ' +
|
|
895
|
+
'If tests FAIL or the reviewer REJECTs, dispatch the `writer` to fix and then re-verify — ' +
|
|
896
|
+
'at most 2 fix rounds, after which surface the outcome to the user rather than looping.',
|
|
897
|
+
},
|
|
898
|
+
researcher: {
|
|
899
|
+
afterReminder: 'The researcher sub-agent just finished. Before you finalize, use the Task tool to dispatch the ' +
|
|
900
|
+
'`reasoner` gate to judge whether the research is complete and well-sourced for the original ' +
|
|
901
|
+
'question. If it returns NEEDS-MORE, send the researcher back — at most 2 rounds, then surface.',
|
|
902
|
+
},
|
|
889
903
|
};
|
|
890
904
|
const RESEARCH_TOOLS = [
|
|
891
905
|
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
|
@@ -1623,327 +1637,6 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
1623
1637
|
console.log('🔒 Persistent session background consumer exited');
|
|
1624
1638
|
}
|
|
1625
1639
|
}
|
|
1626
|
-
/**
|
|
1627
|
-
* Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
|
|
1628
|
-
* Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
|
|
1629
|
-
* if the verdict is REJECT. A reviewer failure must never crash the consumer.
|
|
1630
|
-
* Public so ClaudeLLMStream can call it via this.#llmRef.spawnReviewer().
|
|
1631
|
-
*/
|
|
1632
|
-
// writerOutput intentionally NOT fed to the reviewer (neutrality); kept for signature stability
|
|
1633
|
-
async spawnReviewer(agentId, writerOutput, emitter) {
|
|
1634
|
-
// Dedup guard — SubagentStop may fire more than once for the same agent_id.
|
|
1635
|
-
if (this.#dispatchedFor.has(agentId))
|
|
1636
|
-
return;
|
|
1637
|
-
this.#dispatchedFor.add(agentId);
|
|
1638
|
-
try {
|
|
1639
|
-
// Get the actual diff to give reviewer concrete evidence instead of just the narrative
|
|
1640
|
-
let gitDiff = '';
|
|
1641
|
-
try {
|
|
1642
|
-
const { execSync } = await import('child_process');
|
|
1643
|
-
const diffStat = execSync('git diff HEAD~1 HEAD --stat 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
|
|
1644
|
-
const diff = execSync('git diff HEAD~1 HEAD 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
|
|
1645
|
-
gitDiff = diffStat ? `\n\n<git_diff_stat>\n${diffStat}\n</git_diff_stat>\n\n<git_diff>\n${diff.slice(0, 6000)}\n</git_diff>` : '';
|
|
1646
|
-
}
|
|
1647
|
-
catch {
|
|
1648
|
-
// non-fatal: if git fails, proceed without diff
|
|
1649
|
-
}
|
|
1650
|
-
const prompt = [
|
|
1651
|
-
'Review the change below for correctness, spec/requirement adherence, and obvious bugs.',
|
|
1652
|
-
'The git diff is the AUTHORITATIVE source of what changed. Judge it on its own merits —',
|
|
1653
|
-
'you are deliberately NOT given the writer\'s rationale or the session index; form an independent verdict from the code and the project\'s own documented standards.',
|
|
1654
|
-
'Read the actual modified files and their callers as needed (you are not limited to the diff).',
|
|
1655
|
-
'End your reply with exactly `VERDICT: ACCEPT` or `VERDICT: REJECT`.',
|
|
1656
|
-
gitDiff,
|
|
1657
|
-
].join('\n');
|
|
1658
|
-
// Do NOT pass agents here — the reviewer must be review-only and must not
|
|
1659
|
-
// be able to spawn writer/researcher/reasoner sub-agents. Passing an empty
|
|
1660
|
-
// agents roster prevents any SubagentStop(agent_type==='writer') from
|
|
1661
|
-
// firing inside this one-shot query and re-arming the backstop loop.
|
|
1662
|
-
const reviewerOptions = {
|
|
1663
|
-
cwd: getSubagentsDir(this.#opts.workingDirectory),
|
|
1664
|
-
permissionMode: 'default',
|
|
1665
|
-
systemPrompt: NAMED_AGENTS.reviewer.prompt,
|
|
1666
|
-
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
|
|
1667
|
-
hooks: {
|
|
1668
|
-
PreToolUse: [
|
|
1669
|
-
{
|
|
1670
|
-
matcher: '.*',
|
|
1671
|
-
hooks: [async (input) => {
|
|
1672
|
-
const toolName = input?.tool_name || 'unknown';
|
|
1673
|
-
const toolInput = input?.tool_input || {};
|
|
1674
|
-
emitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: 'reviewer' });
|
|
1675
|
-
return {};
|
|
1676
|
-
}],
|
|
1677
|
-
},
|
|
1678
|
-
{
|
|
1679
|
-
matcher: '.*',
|
|
1680
|
-
hooks: [async (input) => {
|
|
1681
|
-
const toolName = input?.tool_name || '';
|
|
1682
|
-
const toolInput = input?.tool_input;
|
|
1683
|
-
if ((toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') &&
|
|
1684
|
-
!/\.(md|markdown|mdx|txt|rst|adoc)$/i.test(String(toolInput?.file_path))) {
|
|
1685
|
-
return {
|
|
1686
|
-
hookSpecificOutput: {
|
|
1687
|
-
hookEventName: 'PreToolUse',
|
|
1688
|
-
permissionDecision: 'deny',
|
|
1689
|
-
},
|
|
1690
|
-
reason: 'reviewer may only write documentation files (.md/.markdown/.mdx/.txt/.rst/.adoc)',
|
|
1691
|
-
};
|
|
1692
|
-
}
|
|
1693
|
-
return {};
|
|
1694
|
-
}],
|
|
1695
|
-
},
|
|
1696
|
-
],
|
|
1697
|
-
PostToolUse: [{
|
|
1698
|
-
matcher: '.*',
|
|
1699
|
-
hooks: [async (input) => {
|
|
1700
|
-
const toolName = input?.tool_name || 'unknown';
|
|
1701
|
-
const toolInput = input?.tool_input || {};
|
|
1702
|
-
const toolResponse = input?.tool_response;
|
|
1703
|
-
emitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: 'reviewer' });
|
|
1704
|
-
return {};
|
|
1705
|
-
}],
|
|
1706
|
-
}],
|
|
1707
|
-
// Self-review gate — block ONCE (stop_hook_active-guarded) so the reviewer
|
|
1708
|
-
// re-checks its own verdict before returning, instead of stopping on a
|
|
1709
|
-
// first-pass judgment.
|
|
1710
|
-
Stop: [{
|
|
1711
|
-
matcher: '.*',
|
|
1712
|
-
hooks: [async (input) => {
|
|
1713
|
-
if (input?.stop_hook_active)
|
|
1714
|
-
return {};
|
|
1715
|
-
return { decision: 'block', reason: 'Before you finalize: re-read your findings against the diff once. Confirm each BLOCKER/MAJOR is real and reproducible (not a style nit or a false positive), and that your verdict matches the severity of what you actually found. Then end with exactly `VERDICT: ACCEPT` or `VERDICT: REJECT`.' };
|
|
1716
|
-
}],
|
|
1717
|
-
}],
|
|
1718
|
-
},
|
|
1719
|
-
};
|
|
1720
|
-
console.log(`[DISPATCH] spawning reviewer for agentId=${agentId.slice(0, 8)}`);
|
|
1721
|
-
const reviewerQuery = query({ prompt, options: reviewerOptions });
|
|
1722
|
-
this.#activeQueries.add(reviewerQuery);
|
|
1723
|
-
this.#activeQueriesById.set(agentId, reviewerQuery);
|
|
1724
|
-
let reviewerText = '';
|
|
1725
|
-
try {
|
|
1726
|
-
for await (const msg of reviewerQuery) {
|
|
1727
|
-
const m = msg;
|
|
1728
|
-
if (m.type === 'result' && m.result) {
|
|
1729
|
-
reviewerText = String(m.result);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
finally {
|
|
1734
|
-
this.#activeQueries.delete(reviewerQuery);
|
|
1735
|
-
this.#activeQueriesById.delete(agentId);
|
|
1736
|
-
}
|
|
1737
|
-
const verdictMatch = reviewerText.match(/VERDICT:\s*(ACCEPT|REJECT)/i);
|
|
1738
|
-
const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : null;
|
|
1739
|
-
if (verdict === 'REJECT') {
|
|
1740
|
-
console.log(`[DISPATCH] review REJECT for agentId=${agentId.slice(0, 8)}`);
|
|
1741
|
-
statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: reviewerText });
|
|
1742
|
-
emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'REJECT', review: reviewerText });
|
|
1743
|
-
}
|
|
1744
|
-
else {
|
|
1745
|
-
console.log(`[DISPATCH] review ACCEPT for agentId=${agentId.slice(0, 8)}`);
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
catch (err) {
|
|
1749
|
-
console.error('[DISPATCH] reviewer spawn failed (non-fatal):', err);
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
/**
|
|
1753
|
-
* Dispatcher v1 — auto-spawn a tester after a writer sub-agent completes,
|
|
1754
|
-
* in parallel with spawnReviewer. Runs tests against the diff and emits
|
|
1755
|
-
* dispatch_rejected with verdict 'TEST-FAIL' if they fail.
|
|
1756
|
-
*/
|
|
1757
|
-
async spawnTester(agentId, _writerOutput, emitter) {
|
|
1758
|
-
// Use a scoped key so reviewer's dedup guard (keyed on raw agentId) doesn't block us.
|
|
1759
|
-
const testerKey = `${agentId}:tester`;
|
|
1760
|
-
if (this.#dispatchedFor.has(testerKey))
|
|
1761
|
-
return;
|
|
1762
|
-
this.#dispatchedFor.add(testerKey);
|
|
1763
|
-
try {
|
|
1764
|
-
let gitDiff = '';
|
|
1765
|
-
try {
|
|
1766
|
-
const { execSync } = await import('child_process');
|
|
1767
|
-
const diffStat = execSync('git diff HEAD~1 HEAD --stat 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
|
|
1768
|
-
const diff = execSync('git diff HEAD~1 HEAD 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
|
|
1769
|
-
gitDiff = diffStat ? `\n\n<git_diff_stat>\n${diffStat}\n</git_diff_stat>\n\n<git_diff>\n${diff.slice(0, 6000)}\n</git_diff>` : '';
|
|
1770
|
-
}
|
|
1771
|
-
catch {
|
|
1772
|
-
// non-fatal
|
|
1773
|
-
}
|
|
1774
|
-
const prompt = [
|
|
1775
|
-
'Run tests and verify that the change below did not introduce regressions.',
|
|
1776
|
-
'Use the git diff as your scope — run the full existing test suite to establish a baseline,',
|
|
1777
|
-
'then target tests at the specific changed files/functions.',
|
|
1778
|
-
'If no test suite exists, run the build or execute the changed code directly to confirm it works.',
|
|
1779
|
-
'End your reply with exactly `RESULT: PASS` or `RESULT: FAIL` followed by a brief summary.',
|
|
1780
|
-
gitDiff,
|
|
1781
|
-
].join('\n');
|
|
1782
|
-
const testerOptions = {
|
|
1783
|
-
cwd: getSubagentsDir(this.#opts.workingDirectory),
|
|
1784
|
-
permissionMode: 'default',
|
|
1785
|
-
systemPrompt: NAMED_AGENTS.tester.prompt,
|
|
1786
|
-
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
|
|
1787
|
-
hooks: {
|
|
1788
|
-
PreToolUse: [{
|
|
1789
|
-
matcher: '.*',
|
|
1790
|
-
hooks: [async (input) => {
|
|
1791
|
-
const toolName = input?.tool_name || 'unknown';
|
|
1792
|
-
const toolInput = input?.tool_input || {};
|
|
1793
|
-
emitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: 'tester' });
|
|
1794
|
-
return {};
|
|
1795
|
-
}],
|
|
1796
|
-
}],
|
|
1797
|
-
PostToolUse: [{
|
|
1798
|
-
matcher: '.*',
|
|
1799
|
-
hooks: [async (input) => {
|
|
1800
|
-
const toolName = input?.tool_name || 'unknown';
|
|
1801
|
-
const toolInput = input?.tool_input || {};
|
|
1802
|
-
const toolResponse = input?.tool_response;
|
|
1803
|
-
emitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: 'tester' });
|
|
1804
|
-
return {};
|
|
1805
|
-
}],
|
|
1806
|
-
}],
|
|
1807
|
-
// Self-review gate — block ONCE (stop_hook_active-guarded) so the tester
|
|
1808
|
-
// re-checks its own conclusion before returning.
|
|
1809
|
-
Stop: [{
|
|
1810
|
-
matcher: '.*',
|
|
1811
|
-
hooks: [async (input) => {
|
|
1812
|
-
if (input?.stop_hook_active)
|
|
1813
|
-
return {};
|
|
1814
|
-
return { decision: 'block', reason: 'Before you finalize: re-check your conclusion once. Did you actually RUN the commands (not assume the outcome)? Does your result match the real output, and did you distinguish a genuine regression from a flaky/environment failure? Then end with exactly `RESULT: PASS` or `RESULT: FAIL` followed by a brief summary.' };
|
|
1815
|
-
}],
|
|
1816
|
-
}],
|
|
1817
|
-
},
|
|
1818
|
-
};
|
|
1819
|
-
console.log(`[DISPATCH] spawning tester for agentId=${agentId.slice(0, 8)}`);
|
|
1820
|
-
const testerQuery = query({ prompt, options: testerOptions });
|
|
1821
|
-
this.#activeQueries.add(testerQuery);
|
|
1822
|
-
this.#activeQueriesById.set(testerKey, testerQuery);
|
|
1823
|
-
let testerText = '';
|
|
1824
|
-
try {
|
|
1825
|
-
for await (const msg of testerQuery) {
|
|
1826
|
-
const m = msg;
|
|
1827
|
-
if (m.type === 'result' && m.result) {
|
|
1828
|
-
testerText = String(m.result);
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
finally {
|
|
1833
|
-
this.#activeQueries.delete(testerQuery);
|
|
1834
|
-
this.#activeQueriesById.delete(testerKey);
|
|
1835
|
-
}
|
|
1836
|
-
const resultMatch = testerText.match(/RESULT:\s*(PASS|FAIL)/i);
|
|
1837
|
-
const result = resultMatch ? resultMatch[1].toUpperCase() : null;
|
|
1838
|
-
if (result === 'FAIL') {
|
|
1839
|
-
console.log(`[DISPATCH] tester FAIL for agentId=${agentId.slice(0, 8)}`);
|
|
1840
|
-
statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: testerText });
|
|
1841
|
-
emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'TEST-FAIL', review: testerText });
|
|
1842
|
-
}
|
|
1843
|
-
else {
|
|
1844
|
-
console.log(`[DISPATCH] tester PASS for agentId=${agentId.slice(0, 8)}`);
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
catch (err) {
|
|
1848
|
-
console.error('[DISPATCH] tester spawn failed (non-fatal):', err);
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
/**
|
|
1852
|
-
* Dispatcher v1 — research gate: vet a researcher sub-agent's output before
|
|
1853
|
-
* it reaches the main agent. Emits dispatch_rejected with verdict 'NEEDS-MORE'
|
|
1854
|
-
* (distinct from reviewer's 'REJECT') so the frontend can tell them apart.
|
|
1855
|
-
*/
|
|
1856
|
-
async spawnResearchGate(agentId, researchOutput, emitter) {
|
|
1857
|
-
// Dedup guard — SubagentStop may fire more than once for the same agent_id.
|
|
1858
|
-
if (this.#dispatchedFor.has(agentId))
|
|
1859
|
-
return;
|
|
1860
|
-
this.#dispatchedFor.add(agentId);
|
|
1861
|
-
try {
|
|
1862
|
-
const idxPathGate = (this.#sessionId && this.#opts.workingDirectory)
|
|
1863
|
-
? getIndexPath(this.#sessionId, this.#opts.workingDirectory)
|
|
1864
|
-
: null;
|
|
1865
|
-
const prompt = [
|
|
1866
|
-
'Use the reasoner sub-agent to vet this research output.',
|
|
1867
|
-
'Determine whether the research is sufficient to answer the original question.',
|
|
1868
|
-
'End your reply with exactly `GATE: PASS` or `GATE: NEEDS-MORE`.',
|
|
1869
|
-
...(idxPathGate ? [`The session index is at ${idxPathGate} — you MUST read it before reviewing.`] : []),
|
|
1870
|
-
'',
|
|
1871
|
-
'<research_output>',
|
|
1872
|
-
researchOutput.slice(0, 8000),
|
|
1873
|
-
'</research_output>',
|
|
1874
|
-
].join('\n');
|
|
1875
|
-
// Do NOT pass agents here — the research-gate reasoner must be review-only
|
|
1876
|
-
// and must not be able to spawn sub-agents. Same rationale as spawnReviewer:
|
|
1877
|
-
// an agents roster would allow delegation back to the writer, which would
|
|
1878
|
-
// fire SubagentStop(agent_type==='writer') and re-arm the backstop.
|
|
1879
|
-
const gateOptions = {
|
|
1880
|
-
cwd: getSubagentsDir(this.#opts.workingDirectory),
|
|
1881
|
-
permissionMode: 'default',
|
|
1882
|
-
systemPrompt: NAMED_AGENTS.reasoner.prompt,
|
|
1883
|
-
hooks: {
|
|
1884
|
-
PreToolUse: [{
|
|
1885
|
-
matcher: '.*',
|
|
1886
|
-
hooks: [async (input) => {
|
|
1887
|
-
const toolName = input?.tool_name || 'unknown';
|
|
1888
|
-
const toolInput = input?.tool_input || {};
|
|
1889
|
-
emitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: 'reasoner' });
|
|
1890
|
-
return {};
|
|
1891
|
-
}],
|
|
1892
|
-
}],
|
|
1893
|
-
PostToolUse: [{
|
|
1894
|
-
matcher: '.*',
|
|
1895
|
-
hooks: [async (input) => {
|
|
1896
|
-
const toolName = input?.tool_name || 'unknown';
|
|
1897
|
-
const toolInput = input?.tool_input || {};
|
|
1898
|
-
const toolResponse = input?.tool_response;
|
|
1899
|
-
emitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: 'reasoner' });
|
|
1900
|
-
return {};
|
|
1901
|
-
}],
|
|
1902
|
-
}],
|
|
1903
|
-
// Self-review gate — block ONCE (stop_hook_active-guarded) so the gate
|
|
1904
|
-
// re-checks its own PASS/NEEDS-MORE call before returning.
|
|
1905
|
-
Stop: [{
|
|
1906
|
-
matcher: '.*',
|
|
1907
|
-
hooks: [async (input) => {
|
|
1908
|
-
if (input?.stop_hook_active)
|
|
1909
|
-
return {};
|
|
1910
|
-
return { decision: 'block', reason: 'Before you finalize: re-check your gate decision once. Is the research genuinely complete and well-sourced for the original question — not passing thin work, nor failing solid work? Then end with exactly `GATE: PASS` or `GATE: NEEDS-MORE`.' };
|
|
1911
|
-
}],
|
|
1912
|
-
}],
|
|
1913
|
-
},
|
|
1914
|
-
};
|
|
1915
|
-
console.log(`[DISPATCH] spawning research-gate for agentId=${agentId.slice(0, 8)}`);
|
|
1916
|
-
const gateQuery = query({ prompt, options: gateOptions });
|
|
1917
|
-
this.#activeQueries.add(gateQuery);
|
|
1918
|
-
this.#activeQueriesById.set(agentId, gateQuery);
|
|
1919
|
-
let review = '';
|
|
1920
|
-
try {
|
|
1921
|
-
for await (const msg of gateQuery) {
|
|
1922
|
-
const m = msg;
|
|
1923
|
-
if (m.type === 'result' && m.result) {
|
|
1924
|
-
review = String(m.result);
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
finally {
|
|
1929
|
-
this.#activeQueries.delete(gateQuery);
|
|
1930
|
-
this.#activeQueriesById.delete(agentId);
|
|
1931
|
-
}
|
|
1932
|
-
const gateMatch = review.match(/GATE:\s*(PASS|NEEDS-MORE)/i);
|
|
1933
|
-
const gateVerdict = gateMatch ? gateMatch[1].toUpperCase() : null;
|
|
1934
|
-
if (gateVerdict === 'NEEDS-MORE') {
|
|
1935
|
-
console.log(`[DISPATCH] research-gate NEEDS-MORE for agentId=${agentId.slice(0, 8)}`);
|
|
1936
|
-
statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: review });
|
|
1937
|
-
emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'NEEDS-MORE', review });
|
|
1938
|
-
}
|
|
1939
|
-
else {
|
|
1940
|
-
console.log(`[DISPATCH] research-gate PASS for agentId=${agentId.slice(0, 8)}`);
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
catch (err) {
|
|
1944
|
-
console.error('[DISPATCH] research-gate spawn failed (non-fatal):', err);
|
|
1945
|
-
}
|
|
1946
|
-
}
|
|
1947
1640
|
chat({ chatCtx, toolCtx, connOptions = DEFAULT_API_CONNECT_OPTIONS, abortController, }) {
|
|
1948
1641
|
return new ClaudeLLMStream(this, {
|
|
1949
1642
|
chatCtx,
|
|
@@ -2195,6 +1888,23 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2195
1888
|
console.log('[LIFECYCLE-PROBE] PostToolUse on', toolName, 'agent_id=', agentId, 'ts=', Date.now());
|
|
2196
1889
|
}
|
|
2197
1890
|
this.#eventEmitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: agentTypePost || 'main' });
|
|
1891
|
+
// Pattern #1 verifier chaining: when the MAIN agent's Task sub-agent of a
|
|
1892
|
+
// chained type finishes, inject a reminder so the main agent dispatches the
|
|
1893
|
+
// next verifier(s) as real Task sub-agents (verdicts return inline). Gated to
|
|
1894
|
+
// the main thread (agent_type absent) so verifiers never re-chain themselves.
|
|
1895
|
+
if (!agentTypePost && (toolName === 'Task' || toolName === 'Agent')) {
|
|
1896
|
+
const subType = String(toolInput?.subagent_type || toolInput?.agent_type || '');
|
|
1897
|
+
const link = subType ? VERIFIER_CHAIN[subType] : undefined;
|
|
1898
|
+
if (link?.afterReminder) {
|
|
1899
|
+
console.log(`[DISPATCH] Pattern#1 chain: ${subType} done → reminding main agent to verify`);
|
|
1900
|
+
return {
|
|
1901
|
+
hookSpecificOutput: {
|
|
1902
|
+
hookEventName: 'PostToolUse',
|
|
1903
|
+
additionalContext: link.afterReminder,
|
|
1904
|
+
},
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
2198
1908
|
return {};
|
|
2199
1909
|
}]
|
|
2200
1910
|
}],
|
|
@@ -2231,15 +1941,17 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2231
1941
|
if (!fromSubagent && sid && this.#opts.workingDirectory) {
|
|
2232
1942
|
this.#llmRef.triggerStoreUpdate(sid, this.#opts.workingDirectory);
|
|
2233
1943
|
}
|
|
2234
|
-
// Recall
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
2237
|
-
//
|
|
1944
|
+
// Recall is best-effort and must never approach the SDK's 30s hook budget.
|
|
1945
|
+
// buildRecallInjection is already self-bounding — the query embed has a tight
|
|
1946
|
+
// timeout (falls back to keyword INSIDE recall) and the SQL is local/ms. This
|
|
1947
|
+
// outer race is only a catastrophic-hang backstop (e.g. a momentarily locked
|
|
1948
|
+
// store), set well above the embed budget so a slow embed degrades to keyword
|
|
1949
|
+
// rather than being cut to nothing.
|
|
2238
1950
|
const recalled = fromSubagent
|
|
2239
1951
|
? ''
|
|
2240
1952
|
: await Promise.race([
|
|
2241
1953
|
buildRecallInjection(sid, this.#opts.workingDirectory, String(input?.prompt || '')),
|
|
2242
|
-
new Promise((res) => setTimeout(() => res(''),
|
|
1954
|
+
new Promise((res) => setTimeout(() => res(''), 6000)),
|
|
2243
1955
|
]);
|
|
2244
1956
|
const additionalContext = recalled ? `${reminder}\n\n${recalled}` : reminder;
|
|
2245
1957
|
console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars)${recalled ? ` + recall (${recalled.length} chars)` : ''} for prompt="${promptPreview}..." [tool budget reset to 0/${TOOL_CALL_BUDGET}]`);
|
|
@@ -2524,27 +2236,11 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2524
2236
|
return {};
|
|
2525
2237
|
}]
|
|
2526
2238
|
}],
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
2530
|
-
//
|
|
2531
|
-
//
|
|
2532
|
-
Stop: [{
|
|
2533
|
-
matcher: '.*',
|
|
2534
|
-
hooks: [async (input) => {
|
|
2535
|
-
const inFlight = Array.isArray(input?.background_tasks) ? input.background_tasks : [];
|
|
2536
|
-
if (input?.stop_hook_active || inFlight.length === 0)
|
|
2537
|
-
return {};
|
|
2538
|
-
const labels = inFlight
|
|
2539
|
-
.map((t) => t?.agent_type || t?.name || t?.command || t?.type || 'task')
|
|
2540
|
-
.slice(0, 6);
|
|
2541
|
-
console.log(`🛑 Stop gate: ${inFlight.length} background task(s) in flight → blocking once for self-review`);
|
|
2542
|
-
return {
|
|
2543
|
-
decision: 'block',
|
|
2544
|
-
reason: `Before you end your turn: ${inFlight.length} background task(s) are still running (${labels.join(', ')}). Do NOT go idle. Either wait for them and synthesize their results into your answer, or explicitly tell the user what is still running and what you will do when it finishes.`,
|
|
2545
|
-
};
|
|
2546
|
-
}]
|
|
2547
|
-
}],
|
|
2239
|
+
// (Removed the end-of-turn idle-guard Stop gate: it polled `background_tasks`
|
|
2240
|
+
// — Bash background jobs, never the verifier queries — so it couldn't coordinate
|
|
2241
|
+
// them, and its block-loop was a hang surface. Pattern #1 chaining via the
|
|
2242
|
+
// PostToolUse-on-Task reminder makes verifiers return inline, so the main agent
|
|
2243
|
+
// sees their verdicts deterministically without an end-turn gate.)
|
|
2548
2244
|
SubagentStart: [{
|
|
2549
2245
|
matcher: '.*',
|
|
2550
2246
|
hooks: [async (input) => {
|
|
@@ -2562,34 +2258,9 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2562
2258
|
const aid = input?.agent_id ?? ('sa-' + Date.now());
|
|
2563
2259
|
statusManager.upsertDispatch(aid, { subagentType: at, dispatchState: 'completed', artifact: msg });
|
|
2564
2260
|
this.#eventEmitter.emit('task_completed', { agent_type: at, agent_id: aid, last_assistant_message: String(msg).slice(0, 400) });
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2567
|
-
|
|
2568
|
-
return {};
|
|
2569
|
-
// Verifier chaining — driven by the plain VERIFIER_CHAIN lookup
|
|
2570
|
-
// (replaces the hardcoded writer/researcher branches).
|
|
2571
|
-
const chain = at ? VERIFIER_CHAIN[at] : undefined;
|
|
2572
|
-
if (chain?.then?.length && msg) {
|
|
2573
|
-
const spawn = (role) => {
|
|
2574
|
-
if (role === 'reviewer')
|
|
2575
|
-
return this.#llmRef.spawnReviewer(aid, msg, this.#eventEmitter);
|
|
2576
|
-
if (role === 'tester')
|
|
2577
|
-
return this.#llmRef.spawnTester(aid, msg, this.#eventEmitter);
|
|
2578
|
-
if (role === 'reasoner' || role === 'gate')
|
|
2579
|
-
return this.#llmRef.spawnResearchGate(aid, msg, this.#eventEmitter);
|
|
2580
|
-
console.warn(`[DISPATCH] unknown chain target '${role}' for ${at} — skipped`);
|
|
2581
|
-
return Promise.resolve();
|
|
2582
|
-
};
|
|
2583
|
-
if (chain.mode === 'sequential') {
|
|
2584
|
-
// Await in order WITHOUT blocking the hook return (fire the chain async).
|
|
2585
|
-
void (async () => { for (const r of chain.then)
|
|
2586
|
-
await spawn(r); })();
|
|
2587
|
-
}
|
|
2588
|
-
else {
|
|
2589
|
-
for (const r of chain.then)
|
|
2590
|
-
void spawn(r);
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2261
|
+
// Verifier chaining is Pattern #1 now: the PostToolUse-on-Task hook injects a
|
|
2262
|
+
// reminder into the MAIN agent, which dispatches the reviewer/tester as real
|
|
2263
|
+
// Task sub-agents (verdicts return inline). No out-of-band spawns from here.
|
|
2593
2264
|
return {};
|
|
2594
2265
|
}]
|
|
2595
2266
|
}],
|
package/dist/embedder.d.ts
CHANGED
|
@@ -25,4 +25,4 @@ import { type Embedder } from './session-store.js';
|
|
|
25
25
|
* Returns an Embedder, or null if embeddings are disabled or no key is configured.
|
|
26
26
|
* The returned function degrades to null on any per-call failure (keyword-only recall).
|
|
27
27
|
*/
|
|
28
|
-
export declare function getEmbedder(): Promise<Embedder | null>;
|
|
28
|
+
export declare function getEmbedder(timeoutMs?: number): Promise<Embedder | null>;
|
package/dist/embedder.js
CHANGED
|
@@ -25,7 +25,16 @@ const OPENROUTER_URL = process.env.OPENROUTER_EMBED_URL || 'https://openrouter.a
|
|
|
25
25
|
const MODEL_ID = EMBED_MODEL;
|
|
26
26
|
const MAX_BATCH = 64; // inputs per request (OpenAI-compatible arrays)
|
|
27
27
|
const REQUEST_TIMEOUT_MS = Number(process.env.OSBORN_EMBED_TIMEOUT_MS || 20_000);
|
|
28
|
-
let
|
|
28
|
+
let lastEmbedWarn = 0;
|
|
29
|
+
/** Throttled failure log (max ~1/30s) so a bulk re-embed can't spam, but embedded=0 is
|
|
30
|
+
* never silent — every failure surfaces a reason (status/network) in the machine logs. */
|
|
31
|
+
function embedWarn(msg) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (now - lastEmbedWarn > 30_000) {
|
|
34
|
+
lastEmbedWarn = now;
|
|
35
|
+
console.warn(`[embedder] ${msg}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
29
38
|
function apiKey() {
|
|
30
39
|
return process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_WORKSPACE_API_KEY;
|
|
31
40
|
}
|
|
@@ -52,9 +61,9 @@ function normalizeAndQuantize(floats) {
|
|
|
52
61
|
return out;
|
|
53
62
|
}
|
|
54
63
|
/** Embed one batch (≤ MAX_BATCH inputs). Returns aligned int8 vectors, or null on any failure. */
|
|
55
|
-
async function embedBatch(inputs, key) {
|
|
64
|
+
async function embedBatch(inputs, key, timeoutMs) {
|
|
56
65
|
const controller = new AbortController();
|
|
57
|
-
const timer = setTimeout(() => controller.abort(),
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
58
67
|
try {
|
|
59
68
|
const res = await fetch(OPENROUTER_URL, {
|
|
60
69
|
method: 'POST',
|
|
@@ -72,26 +81,35 @@ async function embedBatch(inputs, key) {
|
|
|
72
81
|
}),
|
|
73
82
|
signal: controller.signal,
|
|
74
83
|
});
|
|
75
|
-
if (!res.ok)
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const body = await res.text().catch(() => '');
|
|
86
|
+
embedWarn(`OpenRouter ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
|
76
87
|
return null;
|
|
88
|
+
}
|
|
77
89
|
const json = await res.json();
|
|
78
90
|
const data = json?.data;
|
|
79
|
-
if (!Array.isArray(data) || !data.length)
|
|
91
|
+
if (!Array.isArray(data) || !data.length) {
|
|
92
|
+
embedWarn('empty/invalid embeddings response');
|
|
80
93
|
return null;
|
|
94
|
+
}
|
|
81
95
|
// OpenAI-compatible: each entry carries its input `index`; align by it.
|
|
96
|
+
// Fall back to array order if `index` is absent so a missing field never voids the batch.
|
|
82
97
|
const byIndex = new Array(inputs.length).fill(null);
|
|
83
|
-
|
|
84
|
-
const idx = typeof d?.index === 'number' ? d.index :
|
|
98
|
+
data.forEach((d, pos) => {
|
|
99
|
+
const idx = typeof d?.index === 'number' ? d.index : pos;
|
|
85
100
|
const emb = d?.embedding;
|
|
86
101
|
if (idx >= 0 && idx < inputs.length && Array.isArray(emb)) {
|
|
87
102
|
byIndex[idx] = normalizeAndQuantize(emb);
|
|
88
103
|
}
|
|
104
|
+
});
|
|
105
|
+
if (byIndex.some((v) => v == null)) {
|
|
106
|
+
embedWarn('response missing/misaligned embeddings');
|
|
107
|
+
return null;
|
|
89
108
|
}
|
|
90
|
-
if (byIndex.some((v) => v == null))
|
|
91
|
-
return null; // malformed / partial response
|
|
92
109
|
return byIndex;
|
|
93
110
|
}
|
|
94
|
-
catch {
|
|
111
|
+
catch (err) {
|
|
112
|
+
embedWarn(`request failed: ${err?.name || ''} ${err?.message || err}`);
|
|
95
113
|
return null; // network error, timeout/abort, bad JSON — degrade to keyword-only
|
|
96
114
|
}
|
|
97
115
|
finally {
|
|
@@ -102,12 +120,17 @@ async function embedBatch(inputs, key) {
|
|
|
102
120
|
* Returns an Embedder, or null if embeddings are disabled or no key is configured.
|
|
103
121
|
* The returned function degrades to null on any per-call failure (keyword-only recall).
|
|
104
122
|
*/
|
|
105
|
-
export async function getEmbedder() {
|
|
106
|
-
if (process.env.OSBORN_EMBED === '0'
|
|
123
|
+
export async function getEmbedder(timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
124
|
+
if (process.env.OSBORN_EMBED === '0')
|
|
107
125
|
return null;
|
|
126
|
+
// Re-read the key EVERY call and never latch off on a missing key: the platform
|
|
127
|
+
// OpenRouter key is hydrated into the env by the secrets layer, which can finish
|
|
128
|
+
// AFTER the first (boot-time) warm call. A permanent latch there disabled embedding
|
|
129
|
+
// for the whole session (embedded=0) even though fast-brain — which reads the key
|
|
130
|
+
// fresh per request — kept working. Re-reading recovers as soon as the key lands.
|
|
108
131
|
const key = apiKey();
|
|
109
132
|
if (!key) {
|
|
110
|
-
|
|
133
|
+
embedWarn('no OPENROUTER_API_KEY in env yet — keyword-only for this call');
|
|
111
134
|
return null;
|
|
112
135
|
}
|
|
113
136
|
const embed = async (texts) => {
|
|
@@ -116,7 +139,7 @@ export async function getEmbedder() {
|
|
|
116
139
|
const out = [];
|
|
117
140
|
for (let i = 0; i < texts.length; i += MAX_BATCH) {
|
|
118
141
|
const chunk = texts.slice(i, i + MAX_BATCH).map((t) => t || ' ');
|
|
119
|
-
const vecs = await embedBatch(chunk, key);
|
|
142
|
+
const vecs = await embedBatch(chunk, key, timeoutMs);
|
|
120
143
|
if (!vecs)
|
|
121
144
|
return null; // any failure → keyword-only for this call
|
|
122
145
|
out.push(...vecs);
|
|
@@ -107,13 +107,15 @@ THE BUDGET — HARD QUANTITATIVE RULE:
|
|
|
107
107
|
THE SUB-AGENTS:
|
|
108
108
|
· researcher (Sonnet) — info gathering, web research, multi-file reads. Read-only outside workspace.
|
|
109
109
|
· reasoner (Opus) — architecture decisions, complex tradeoffs, implementation planning. Read-only.
|
|
110
|
-
· writer (Sonnet) — ALL file changes outside the workspace.
|
|
111
|
-
·
|
|
110
|
+
· writer (Sonnet) — ALL file changes outside the workspace. The ONLY agent with write access outside the workspace.
|
|
111
|
+
· tester (Sonnet) — TDD verifier. Dispatched IN PARALLEL with the writer: writes tests from the SPEC, blind to the writer's implementation. After the writer finishes, its tests are RUN against the new code (PASS/FAIL).
|
|
112
|
+
· reviewer (Sonnet) — dispatched AFTER the writer completes: reads the diff and returns VERDICT: ACCEPT/REJECT.
|
|
113
|
+
· NEVER use the SDK's built-in 'general-purpose' agent — it is not configured for this project and will hit write blocks. Always pick researcher, reasoner, writer, tester, or reviewer explicitly.
|
|
112
114
|
|
|
113
115
|
DIVISION OF LABOR — follow this chain by default for any substantive or code task:
|
|
114
|
-
researcher (gather facts) → planner (
|
|
116
|
+
researcher (gather facts) → planner (step plan for multi-step work) → THEN dispatch writer AND tester IN THE SAME STEP, in parallel: the writer executes the plan while the tester writes tests from the spec, blind to the implementation (TDD) → after the writer returns, RUN the tester's tests against the new code AND dispatch the reviewer on the diff → on test-FAIL or REJECT, send the writer back to fix and re-verify (at most 2 rounds) → you synthesize and speak.
|
|
115
117
|
For a quick factual query: researcher only → you speak.
|
|
116
|
-
NEVER skip tester
|
|
118
|
+
NEVER skip the tester+reviewer verification after a code change — and dispatch the tester IN PARALLEL with the writer (not after), so tests are written independently of the code.
|
|
117
119
|
You may OVERRIDE this chain at any time — stop a running agent, inject, or reorder — because you can see each task's live state. The chain is the default, not a cage.
|
|
118
120
|
Surfacing findings and communicating to the user is YOUR job, not a sub-agent's.
|
|
119
121
|
</turn-shape>
|
package/dist/session-store.d.ts
CHANGED
|
@@ -63,7 +63,9 @@ export interface StoreStats {
|
|
|
63
63
|
export declare function getStorePath(sessionId: string, workingDir: string): string;
|
|
64
64
|
/** Returns the store path if it exists and is non-empty, else null. */
|
|
65
65
|
export declare function storeExists(sessionId: string, workingDir: string): string | null;
|
|
66
|
-
export declare function openStore(dbPath: string
|
|
66
|
+
export declare function openStore(dbPath: string, opts?: {
|
|
67
|
+
readonly?: boolean;
|
|
68
|
+
}): Database.Database;
|
|
67
69
|
/**
|
|
68
70
|
* Build or update the session store incrementally.
|
|
69
71
|
* Ingests the main JSONL + all sub-agent JSONLs from their last stored offsets.
|
package/dist/session-store.js
CHANGED
|
@@ -66,11 +66,23 @@ export function storeExists(sessionId, workingDir) {
|
|
|
66
66
|
// ============================================================
|
|
67
67
|
// DB OPEN / SCHEMA
|
|
68
68
|
// ============================================================
|
|
69
|
-
export function openStore(dbPath) {
|
|
69
|
+
export function openStore(dbPath, opts) {
|
|
70
|
+
// Read-only open for the hot read path (buildRecallInjection, fast-brain). A readonly
|
|
71
|
+
// connection takes only SHARED locks — under WAL it never blocks (or is blocked by) the
|
|
72
|
+
// writer — and it skips the DDL/migration/meta writes below entirely (a reader must not
|
|
73
|
+
// migrate). busy_timeout lets a transient exclusive lock (checkpoint/DDL on the writer)
|
|
74
|
+
// resolve instead of throwing SQLITE_BUSY. Caller guarantees the db exists (storeExists).
|
|
75
|
+
if (opts?.readonly) {
|
|
76
|
+
const rdb = new Database(dbPath, { readonly: true });
|
|
77
|
+
sqliteVec.load(rdb); // vec0 module must be registered per-connection for vec_int8() in recall()
|
|
78
|
+
rdb.pragma('busy_timeout = 5000');
|
|
79
|
+
return rdb;
|
|
80
|
+
}
|
|
70
81
|
const db = new Database(dbPath);
|
|
71
82
|
sqliteVec.load(db);
|
|
72
83
|
db.pragma('journal_mode = WAL');
|
|
73
84
|
db.pragma('synchronous = NORMAL');
|
|
85
|
+
db.pragma('busy_timeout = 5000'); // wait out a transient lock instead of throwing SQLITE_BUSY
|
|
74
86
|
// ── Migration gate ──────────────────────────────────────────────
|
|
75
87
|
// The derived tables (content/fts/vec/sources) are a re-derivable cache of the
|
|
76
88
|
// JSONL, which is the source of truth. If this DB was built by a different
|
|
@@ -256,12 +268,22 @@ function countLines(filePath, upToBytes) {
|
|
|
256
268
|
return c;
|
|
257
269
|
}
|
|
258
270
|
/**
|
|
259
|
-
* Max bytes ingested from a
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
|
|
271
|
+
* Max bytes ingested from a SINGLE JSONL per sweep. Kept small so each synchronous
|
|
272
|
+
* read+parse+gzip+FTS-insert block is short (~sub-300ms) and never stutters realtime
|
|
273
|
+
* voice (VAD) between event-loop yields.
|
|
274
|
+
*/
|
|
275
|
+
const MAX_INGEST_BYTES = 1 * 1024 * 1024;
|
|
276
|
+
/**
|
|
277
|
+
* Max bytes ingested across ALL sources in one turn. This is the cap that actually
|
|
278
|
+
* matters: a session can have HUNDREDS of subagent JSONL files, and on a store
|
|
279
|
+
* migration the offsets reset so every source re-ingests from 0 at once. The old
|
|
280
|
+
* code looped all sources inside ONE synchronous db.transaction — hundreds of MB of
|
|
281
|
+
* synchronous work that pegged the single Node thread for minutes and starved VAD,
|
|
282
|
+
* the SDK's UserPromptSubmit hook (30s timeout), and fast-brain. Bounding the total
|
|
283
|
+
* per turn (and yielding between sources) drains large sessions over many turns
|
|
284
|
+
* without ever monopolizing the loop.
|
|
263
285
|
*/
|
|
264
|
-
const
|
|
286
|
+
const MAX_INGEST_BYTES_PER_TURN = 4 * 1024 * 1024;
|
|
265
287
|
/**
|
|
266
288
|
* Ingest new bytes from a single JSONL file into the store.
|
|
267
289
|
* Reads only from `fromOffset` forward, at most `maxBytes` per call. Returns rows
|
|
@@ -370,21 +392,44 @@ export async function updateSessionStore(sessionId, workingDir, opts) {
|
|
|
370
392
|
const upSrc = db.prepare(`INSERT INTO sources(source, jsonl_path, byte_offset, line_count) VALUES (@source,@path,@off,@lines)
|
|
371
393
|
ON CONFLICT(source) DO UPDATE SET jsonl_path=@path, byte_offset=@off, line_count=@lines`);
|
|
372
394
|
const freshRows = [];
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
lines: (prev?.line_count ?? 0) + res.linesProcessed,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
freshRows.push(...res.inserted);
|
|
395
|
+
let budget = MAX_INGEST_BYTES_PER_TURN;
|
|
396
|
+
let sinceYield = 0;
|
|
397
|
+
for (const s of sources) {
|
|
398
|
+
// Yield to the event loop periodically even across drained sources, so iterating
|
|
399
|
+
// a large source list (statSync + SELECT each) can't itself stall voice/hook.
|
|
400
|
+
if (++sinceYield >= 8) {
|
|
401
|
+
sinceYield = 0;
|
|
402
|
+
await new Promise((r) => setImmediate(r));
|
|
385
403
|
}
|
|
386
|
-
|
|
387
|
-
|
|
404
|
+
if (budget <= 0)
|
|
405
|
+
break; // per-turn cap reached — remaining sources drain next turn
|
|
406
|
+
const prev = getSrc.get(s.source);
|
|
407
|
+
const fromOffset = prev?.byte_offset ?? 0;
|
|
408
|
+
const cap = Math.min(budget, MAX_INGEST_BYTES);
|
|
409
|
+
let res;
|
|
410
|
+
try {
|
|
411
|
+
// Per-source transaction (not one giant txn over all sources) so we can yield
|
|
412
|
+
// between them and never hold the thread for the whole fleet of subagent files.
|
|
413
|
+
res = db.transaction(() => ingestFile(db, s.path, s.source, fromOffset, cap))();
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
continue; // a bad source never blocks the rest
|
|
417
|
+
}
|
|
418
|
+
if (res.inserted.length || res.newOffset !== fromOffset) {
|
|
419
|
+
upSrc.run({
|
|
420
|
+
source: s.source, path: s.path, off: res.newOffset,
|
|
421
|
+
lines: (prev?.line_count ?? 0) + res.linesProcessed,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const consumed = res.newOffset - fromOffset;
|
|
425
|
+
budget -= consumed;
|
|
426
|
+
freshRows.push(...res.inserted);
|
|
427
|
+
// Yield after any source that did real synchronous work.
|
|
428
|
+
if (consumed > 0) {
|
|
429
|
+
sinceYield = 0;
|
|
430
|
+
await new Promise((r) => setImmediate(r));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
388
433
|
opts?.onProgress?.(`content: +${freshRows.length} rows across ${sources.length} sources`);
|
|
389
434
|
// ── Embeddings (best-effort, never blocks the keyword layer) ──
|
|
390
435
|
let embeddedRows = 0;
|