osborn 0.9.143 → 0.9.144

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.
@@ -231,6 +231,19 @@ export declare class ClaudeLLM extends llm.LLM {
231
231
  onCheckpoint: (checkpointId: string) => void;
232
232
  eventEmitter: EventEmitter;
233
233
  }): void;
234
+ /**
235
+ * Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
236
+ * Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
237
+ * if the verdict is REJECT. A reviewer failure must never crash the consumer.
238
+ * Public so ClaudeLLMStream can call it via this.#llmRef.spawnReviewer().
239
+ */
240
+ spawnReviewer(agentId: string, writerOutput: string, emitter: EventEmitter): Promise<void>;
241
+ /**
242
+ * Dispatcher v1 — research gate: vet a researcher sub-agent's output before
243
+ * it reaches the main agent. Emits dispatch_rejected with verdict 'NEEDS-MORE'
244
+ * (distinct from reviewer's 'REJECT') so the frontend can tell them apart.
245
+ */
246
+ spawnResearchGate(agentId: string, researchOutput: string, emitter: EventEmitter): Promise<void>;
234
247
  chat({ chatCtx, toolCtx, connOptions, abortController, }: {
235
248
  chatCtx: llm.ChatContext;
236
249
  toolCtx?: llm.ToolContext;
@@ -555,9 +555,9 @@ export class ClaudeLLM extends llm.LLM {
555
555
  // Active queries — multiple can be running (SDK queues them internally).
556
556
  // We keep ALL references so interrupt() can stop whatever is currently executing.
557
557
  #activeQueries = new Set();
558
- // Dispatcher v1 — maps tool_use_id → subagent_type for Task tool_use blocks
559
- // so that when task_summary fires we know which agent type just finished.
560
- #dispatchAgentTypes = new Map();
558
+ // Dedup guard — prevents double-firing reviewer/gate if SubagentStop fires
559
+ // more than once for the same agent_id (e.g. retry edge cases).
560
+ #dispatchedFor = new Set();
561
561
  constructor(opts = {}) {
562
562
  super();
563
563
  // Session resume/continue options
@@ -970,6 +970,7 @@ export class ClaudeLLM extends llm.LLM {
970
970
  this.#persistentQuery = null;
971
971
  this.#messageChannel = null;
972
972
  this.#backgroundConsumerRunning = false;
973
+ this.#dispatchedFor.clear();
973
974
  console.log('🔒 Persistent session closed');
974
975
  }
975
976
  /**
@@ -1092,29 +1093,6 @@ export class ClaudeLLM extends llm.LLM {
1092
1093
  callbacks.eventEmitter.emit('tts_say', { text: ttsChunk });
1093
1094
  }
1094
1095
  }
1095
- // Dispatcher v1 — record Task tool_use blocks so we can correlate
1096
- // the matching task_summary message to the subagent type.
1097
- if (block.type === 'tool_use' && block.name === 'Task') {
1098
- console.log('[DISPATCH-PROBE] task tool_use', JSON.stringify({ id: block.id, subagent_type: block.input?.subagent_type }));
1099
- this.#dispatchAgentTypes.set(block.id, block.input?.subagent_type);
1100
- statusManager.upsertDispatch(block.id, {
1101
- owner: 'orchestrator',
1102
- subagentType: block.input?.subagent_type,
1103
- dispatchState: 'running',
1104
- });
1105
- }
1106
- }
1107
- }
1108
- // Dispatcher v1 — catch sub-agent completion signals
1109
- if (msg.type === 'system' && msg.subtype === 'task_summary') {
1110
- console.log('[DISPATCH-PROBE] task_summary raw:', JSON.stringify(msg).slice(0, 500));
1111
- const tuid = msg.tool_use_id ?? msg.toolUseId;
1112
- const output = msg.summary ?? msg.result ?? msg.output ?? '';
1113
- const subType = tuid ? this.#dispatchAgentTypes.get(tuid) : undefined;
1114
- statusManager.upsertDispatch(tuid ?? `ts-${Date.now()}`, { subagentType: subType, dispatchState: 'completed', artifact: String(output) });
1115
- console.log(`[DISPATCH] completed type=${subType ?? '?'} tuid=${(tuid ?? '?').slice(0, 8)} len=${String(output).length}`);
1116
- if (subType === 'writer' && output) {
1117
- this.#spawnReviewer(tuid, String(output), callbacks.eventEmitter);
1118
1096
  }
1119
1097
  }
1120
1098
  // Result — marks end of a turn (but we keep consuming for next turn)
@@ -1147,8 +1125,13 @@ export class ClaudeLLM extends llm.LLM {
1147
1125
  * Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
1148
1126
  * Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
1149
1127
  * if the verdict is REJECT. A reviewer failure must never crash the consumer.
1128
+ * Public so ClaudeLLMStream can call it via this.#llmRef.spawnReviewer().
1150
1129
  */
1151
- async #spawnReviewer(tuid, writerOutput, emitter) {
1130
+ async spawnReviewer(agentId, writerOutput, emitter) {
1131
+ // Dedup guard — SubagentStop may fire more than once for the same agent_id.
1132
+ if (this.#dispatchedFor.has(agentId))
1133
+ return;
1134
+ this.#dispatchedFor.add(agentId);
1152
1135
  try {
1153
1136
  const prompt = [
1154
1137
  'Use the reviewer sub-agent to review this writer output for correctness/spec-adherence/obvious bugs.',
@@ -1158,12 +1141,15 @@ export class ClaudeLLM extends llm.LLM {
1158
1141
  writerOutput.slice(0, 8000),
1159
1142
  '</writer_output>',
1160
1143
  ].join('\n');
1144
+ // Do NOT pass agents here — the reviewer must be review-only and must not
1145
+ // be able to spawn writer/researcher/reasoner sub-agents. Passing an empty
1146
+ // agents roster prevents any SubagentStop(agent_type==='writer') from
1147
+ // firing inside this one-shot query and re-arming the backstop loop.
1161
1148
  const reviewerOptions = {
1162
1149
  cwd: this.#opts.workingDirectory,
1163
1150
  permissionMode: 'default',
1164
- agents: NAMED_AGENTS,
1165
1151
  };
1166
- console.log(`[DISPATCH] spawning reviewer for tuid=${tuid.slice(0, 8)}`);
1152
+ console.log(`[DISPATCH] spawning reviewer for agentId=${agentId.slice(0, 8)}`);
1167
1153
  const reviewerQuery = query({ prompt, options: reviewerOptions });
1168
1154
  this.#activeQueries.add(reviewerQuery);
1169
1155
  let reviewerText = '';
@@ -1181,18 +1167,76 @@ export class ClaudeLLM extends llm.LLM {
1181
1167
  const verdictMatch = reviewerText.match(/VERDICT:\s*(ACCEPT|REJECT)/i);
1182
1168
  const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : null;
1183
1169
  if (verdict === 'REJECT') {
1184
- console.log(`[DISPATCH] review REJECT for tuid=${tuid.slice(0, 8)}`);
1185
- statusManager.upsertDispatch(tuid, { dispatchState: 'rejected', artifact: reviewerText });
1186
- emitter.emit('dispatch_rejected', { tuid, verdict: 'REJECT', review: reviewerText });
1170
+ console.log(`[DISPATCH] review REJECT for agentId=${agentId.slice(0, 8)}`);
1171
+ statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: reviewerText });
1172
+ emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'REJECT', review: reviewerText });
1187
1173
  }
1188
1174
  else {
1189
- console.log(`[DISPATCH] review ACCEPT for tuid=${tuid.slice(0, 8)}`);
1175
+ console.log(`[DISPATCH] review ACCEPT for agentId=${agentId.slice(0, 8)}`);
1190
1176
  }
1191
1177
  }
1192
1178
  catch (err) {
1193
1179
  console.error('[DISPATCH] reviewer spawn failed (non-fatal):', err);
1194
1180
  }
1195
1181
  }
1182
+ /**
1183
+ * Dispatcher v1 — research gate: vet a researcher sub-agent's output before
1184
+ * it reaches the main agent. Emits dispatch_rejected with verdict 'NEEDS-MORE'
1185
+ * (distinct from reviewer's 'REJECT') so the frontend can tell them apart.
1186
+ */
1187
+ async spawnResearchGate(agentId, researchOutput, emitter) {
1188
+ // Dedup guard — SubagentStop may fire more than once for the same agent_id.
1189
+ if (this.#dispatchedFor.has(agentId))
1190
+ return;
1191
+ this.#dispatchedFor.add(agentId);
1192
+ try {
1193
+ const prompt = [
1194
+ 'Use the reasoner sub-agent to vet this research output.',
1195
+ 'Determine whether the research is sufficient to answer the original question.',
1196
+ 'End your reply with exactly `GATE: PASS` or `GATE: NEEDS-MORE`.',
1197
+ '',
1198
+ '<research_output>',
1199
+ researchOutput.slice(0, 8000),
1200
+ '</research_output>',
1201
+ ].join('\n');
1202
+ // Do NOT pass agents here — the research-gate reasoner must be review-only
1203
+ // and must not be able to spawn sub-agents. Same rationale as spawnReviewer:
1204
+ // an agents roster would allow delegation back to the writer, which would
1205
+ // fire SubagentStop(agent_type==='writer') and re-arm the backstop.
1206
+ const gateOptions = {
1207
+ cwd: this.#opts.workingDirectory,
1208
+ permissionMode: 'default',
1209
+ };
1210
+ console.log(`[DISPATCH] spawning research-gate for agentId=${agentId.slice(0, 8)}`);
1211
+ const gateQuery = query({ prompt, options: gateOptions });
1212
+ this.#activeQueries.add(gateQuery);
1213
+ let review = '';
1214
+ try {
1215
+ for await (const msg of gateQuery) {
1216
+ const m = msg;
1217
+ if (m.type === 'result' && m.result) {
1218
+ review = String(m.result);
1219
+ }
1220
+ }
1221
+ }
1222
+ finally {
1223
+ this.#activeQueries.delete(gateQuery);
1224
+ }
1225
+ const gateMatch = review.match(/GATE:\s*(PASS|NEEDS-MORE)/i);
1226
+ const gateVerdict = gateMatch ? gateMatch[1].toUpperCase() : null;
1227
+ if (gateVerdict === 'NEEDS-MORE') {
1228
+ console.log(`[DISPATCH] research-gate NEEDS-MORE for agentId=${agentId.slice(0, 8)}`);
1229
+ statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: review });
1230
+ emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'NEEDS-MORE', review });
1231
+ }
1232
+ else {
1233
+ console.log(`[DISPATCH] research-gate PASS for agentId=${agentId.slice(0, 8)}`);
1234
+ }
1235
+ }
1236
+ catch (err) {
1237
+ console.error('[DISPATCH] research-gate spawn failed (non-fatal):', err);
1238
+ }
1239
+ }
1196
1240
  chat({ chatCtx, toolCtx, connOptions = DEFAULT_API_CONNECT_OPTIONS, abortController, }) {
1197
1241
  return new ClaudeLLMStream(this, {
1198
1242
  chatCtx,
@@ -1670,6 +1714,19 @@ class ClaudeLLMStream extends llm.LLMStream {
1670
1714
  matcher: '.*',
1671
1715
  hooks: [async (input) => {
1672
1716
  console.log('[LIFECYCLE-PROBE] SubagentStop', JSON.stringify(input));
1717
+ const at = input?.agent_type;
1718
+ const msg = String(input?.last_assistant_message ?? '');
1719
+ const aid = input?.agent_id ?? ('sa-' + Date.now());
1720
+ statusManager.upsertDispatch(aid, { subagentType: at, dispatchState: 'completed', artifact: msg });
1721
+ // Infinite-loop guard — never re-dispatch the reviewer or reasoner.
1722
+ if (at === 'reviewer' || at === 'reasoner')
1723
+ return {};
1724
+ if (at === 'writer' && msg) {
1725
+ void this.#llmRef.spawnReviewer(aid, msg, this.#eventEmitter);
1726
+ }
1727
+ else if (at === 'researcher' && msg) {
1728
+ void this.#llmRef.spawnResearchGate(aid, msg, this.#eventEmitter);
1729
+ }
1673
1730
  return {};
1674
1731
  }]
1675
1732
  }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.143",
3
+ "version": "0.9.144",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {