@genesislcap/ai-assistant 14.452.0 → 14.452.1

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.
@@ -9,6 +9,7 @@ import type {
9
9
  ChatToolDefinition,
10
10
  ChatToolHandlers,
11
11
  InteractionRequestOptions,
12
+ SubAgentFailureReason,
12
13
  SubAgentRequestOptions,
13
14
  } from '@genesislcap/foundation-ai';
14
15
  import { MalformedFunctionCallError } from '@genesislcap/foundation-ai';
@@ -40,8 +41,19 @@ const DEFAULT_MAX_FOLD_OPERATIONS = 5;
40
41
  // cap reach thousands for full-session capture without the memory blowup.
41
42
  const DEFAULT_MAX_TURN_SNAPSHOTS = 400;
42
43
  const DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5;
43
- const MAX_MALFORMED_RETRIES = 2;
44
+ // Stale tools (advertised in an earlier state, retired now) and fold-hidden tools are
45
+ // self-correcting — the model drops them once guided — so they get a higher loop-protection
46
+ // ceiling than hallucinated names: a few legitimate stale calls across state transitions must
47
+ // not prematurely end the turn. Still bounded so a genuinely stuck loop terminates.
48
+ const MAX_STALE_TOOL_CALLS = DEFAULT_MAX_UNKNOWN_TOOL_CALLS * 2;
49
+ // Gemini in particular emits short bursts of MALFORMED_FUNCTION_CALL; allow more CONSECUTIVE
50
+ // retries. These counters reset on any productive response, so this is a consecutive-failure
51
+ // ceiling, not a per-turn total.
52
+ const MAX_MALFORMED_RETRIES = 5;
44
53
  const MAX_EMPTY_RESPONSE_RETRIES = 3;
54
+ // Transient throws while building the per-turn tool surface or calling the provider retry the
55
+ // SAME iteration up to this many times before propagating, rather than tearing down the turn.
56
+ const MAX_SETUP_TRANSPORT_RETRIES = 3;
45
57
  const SUGGESTIONS_HISTORY_WINDOW = 8;
46
58
 
47
59
  /** Name reserved for the cross-agent handoff tool — injected by OrchestratingDriver. */
@@ -69,8 +81,15 @@ export type ChatHistoryUpdatedEvent = CustomEvent<ReadonlyArray<ChatMessage>>;
69
81
  * @beta
70
82
  */
71
83
  export interface TurnSnapshot {
72
- /** Monotonic counter across the driver's lifetime (does not reset on agent swap). */
73
- turnIndex: number;
84
+ /**
85
+ * Turn identifier, always a string. A driver's own turns are the bare counter
86
+ * (`"0"`, `"1"`, … — monotonic, does not reset on agent swap). Turns forwarded
87
+ * up from a sub-agent are re-labelled under the parent turn that activated them
88
+ * — a sub-agent invoked on parent turn 5 contributes `"5-1"`, `"5-2"`, …, and a
89
+ * nested sub-agent on `"5-2"` contributes `"5-2-1"`, …. See
90
+ * `forwardSubAgentSnapshots`.
91
+ */
92
+ turnIndex: string;
74
93
  /** ISO timestamp captured just before the LLM call. */
75
94
  timestamp: string;
76
95
  /** Name of the agent active when this LLM call ran. */
@@ -218,6 +237,20 @@ export class ChatDriver extends EventTarget implements AiDriver {
218
237
  * `undefined` means the loop has not been stopped early.
219
238
  */
220
239
  private subAgentCompletion: { result: unknown } | undefined;
240
+ /**
241
+ * True when this driver runs as a child sub-agent (created by a parent
242
+ * driver's `invokeSubAgent`). Sub-agents force tool use every turn so a turn
243
+ * can only end via their completion tool, and on any non-completion exit they
244
+ * record a typed `SubAgentFailureReason` instead of appending a
245
+ * user-facing message — the parent decides how to surface the failure.
246
+ */
247
+ private isSubAgent = false;
248
+ /**
249
+ * Set when a sub-agent's tool loop ends without `completeSubAgent` being
250
+ * called. Read by the parent's `invokeSubAgent` to build the `{ ok: false }`
251
+ * branch of `requestSubAgent`. Only ever set when `isSubAgent` is true.
252
+ */
253
+ private subAgentFailure: { reason: SubAgentFailureReason } | undefined;
221
254
  /**
222
255
  * Set by `releaseAgent` inside a top-level tool handler — typically a stateful
223
256
  * agent's terminal-state handler signalling that its flow is complete and the
@@ -405,6 +438,37 @@ export class ChatDriver extends EventTarget implements AiDriver {
405
438
  return this.subAgentCompletion;
406
439
  }
407
440
 
441
+ /**
442
+ * Mark this driver as running as a sub-agent. Called by a parent driver's
443
+ * `invokeSubAgent` immediately after construction, before the first turn.
444
+ * Enables forced tool use and typed failure reporting (see `isSubAgent`).
445
+ */
446
+ markAsSubAgent(): void {
447
+ this.isSubAgent = true;
448
+ }
449
+
450
+ /**
451
+ * Returns the typed failure recorded when a sub-agent run ended without
452
+ * `completeSubAgent`, if any. Called by a parent `ChatDriver` after running
453
+ * this instance as a sub-agent.
454
+ */
455
+ getSubAgentFailure(): { reason: SubAgentFailureReason } | undefined {
456
+ return this.subAgentFailure;
457
+ }
458
+
459
+ /**
460
+ * Record a sub-agent failure reason (first one wins). No-op for top-level
461
+ * agents, so loop-exit sites can call it unconditionally. The parent reads
462
+ * this via `getSubAgentFailure()` and emits the `subagent.failed` meta event
463
+ * under its *own* session — see `invokeSubAgent`. (A child sub-agent runs
464
+ * under a separate session key, so recording here would orphan the event off
465
+ * the user-visible debug-log timeline.)
466
+ */
467
+ private failSubAgent(reason: SubAgentFailureReason): void {
468
+ if (!this.isSubAgent || this.subAgentFailure) return;
469
+ this.subAgentFailure = { reason };
470
+ }
471
+
408
472
  /**
409
473
  * Returns true if `releaseAgent` was called during the most recent turn.
410
474
  * Consumed by the orchestrator to trigger the auto-pin release path.
@@ -424,6 +488,45 @@ export class ChatDriver extends EventTarget implements AiDriver {
424
488
  return this.turnSnapshots;
425
489
  }
426
490
 
491
+ /**
492
+ * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
493
+ * as `kind:'turn'` entries in the exported debug log. The child runs as a
494
+ * separate, discarded driver, so its snapshots would otherwise be lost. Each is
495
+ * re-labelled under the parent turn that activated the sub-agent: the child's
496
+ * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
497
+ * any already-forwarded grand-child labels (strings) have their leading segment
498
+ * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
499
+ *
500
+ * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
501
+ * their labels can repeat — `agentName` on each snapshot disambiguates them.
502
+ */
503
+ private forwardSubAgentSnapshots(childSnapshots: ReadonlyArray<TurnSnapshot>): void {
504
+ if (childSnapshots.length === 0) return;
505
+ // The activating parent turn = the most recent snapshot this driver recorded
506
+ // before entering the tool handler that invoked the sub-agent.
507
+ const parentTurn = Math.max(0, this.globalTurnIndex - 1);
508
+ const ownTurnLabel = new Map<string, string>();
509
+ let ownPos = 0;
510
+ for (const snap of childSnapshots) {
511
+ let turnIndex: string;
512
+ if (!snap.turnIndex.includes('-')) {
513
+ // The child's own turn (a bare counter) → number it under the parent turn.
514
+ ownPos += 1;
515
+ turnIndex = `${parentTurn}-${ownPos}`;
516
+ ownTurnLabel.set(snap.turnIndex, turnIndex);
517
+ } else {
518
+ // An already-forwarded grand-child label — remap its leading segment.
519
+ const [lead, ...rest] = snap.turnIndex.split('-');
520
+ const leadLabel = ownTurnLabel.get(lead) ?? `${parentTurn}-${lead}`;
521
+ turnIndex = [leadLabel, ...rest].join('-');
522
+ }
523
+ this.turnSnapshots.push({ ...snap, turnIndex });
524
+ }
525
+ while (this.turnSnapshots.length > this.maxTurnSnapshots) {
526
+ this.turnSnapshots.shift();
527
+ }
528
+ }
529
+
427
530
  /**
428
531
  * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
429
532
  * before each LLM call — that's the latest point where the prompt, tool
@@ -441,7 +544,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
441
544
  agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
442
545
  }
443
546
  }
444
- const turnIndex = this.globalTurnIndex;
547
+ const turnIndex = String(this.globalTurnIndex);
445
548
  this.globalTurnIndex += 1;
446
549
  this.turnSnapshots.push({
447
550
  turnIndex,
@@ -720,6 +823,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
720
823
 
721
824
  this.busy = true;
722
825
  this.subAgentCompletion = undefined;
826
+ this.subAgentFailure = undefined;
723
827
  this.agentReleaseRequested = false;
724
828
  this.appendToHistory({ role: 'user', content: userInput, attachments });
725
829
  this.turnStartedAt = Date.now();
@@ -776,10 +880,13 @@ export class ChatDriver extends EventTarget implements AiDriver {
776
880
  requestSubAgent: <T = never>(
777
881
  name: string,
778
882
  options?: SubAgentRequestOptions,
779
- ): Promise<T | string> =>
780
- this.invokeSubAgent<T>(name, options).then(({ result, trace }) => {
883
+ ): Promise<
884
+ | { ok: true; result: T; reason?: never }
885
+ | { ok: false; result?: never; reason: SubAgentFailureReason }
886
+ > =>
887
+ this.invokeSubAgent<T>(name, options).then(({ outcome, trace }) => {
781
888
  if (traceCapture) traceCapture.trace = trace;
782
- return result;
889
+ return outcome;
783
890
  }),
784
891
  }),
785
892
  completeSubAgent: (result: unknown): void => {
@@ -812,7 +919,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
812
919
  private async invokeSubAgent<T = never>(
813
920
  name: string,
814
921
  options?: SubAgentRequestOptions,
815
- ): Promise<{ result: T | string; trace: ChatMessage[] }> {
922
+ ): Promise<{
923
+ // Closed union (see ChatToolHandlers.requestSubAgent) — keeps `result`/`reason`
924
+ // accessible for consumers compiled without strictNullChecks.
925
+ outcome:
926
+ | { ok: true; result: T; reason?: never }
927
+ | { ok: false; result?: never; reason: SubAgentFailureReason };
928
+ trace: ChatMessage[];
929
+ }> {
816
930
  const subConfig = this.subAgentsMap.get(name);
817
931
  if (!subConfig) {
818
932
  const available = [...this.subAgentsMap.keys()].join(', ') || '(none)';
@@ -846,6 +960,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
846
960
  ];
847
961
 
848
962
  const child = new ChatDriver(this.providerRegistry);
963
+ // Mark before the first turn so the child forces tool use and reports a
964
+ // typed failure (rather than user-facing text) if it never completes.
965
+ child.markAsSubAgent();
849
966
  child.applyAgent({ ...subConfig, primerHistory: effectivePrimer });
850
967
  // Route interactions back through this driver so widgets render in the
851
968
  // parent's (ultimately the root's) history and resolve via the same
@@ -891,16 +1008,30 @@ export class ChatDriver extends EventTarget implements AiDriver {
891
1008
  }
892
1009
 
893
1010
  const trace = child.getHistory() as ChatMessage[];
1011
+ // Forward the child's per-LLM-call snapshots onto this (parent) driver's
1012
+ // buffer so they show as `kind:'turn'` entries in the exported debug log,
1013
+ // re-numbered under the activating parent turn. Runs for both success and
1014
+ // failure so the sub-agent's turns are always visible.
1015
+ this.forwardSubAgentSnapshots(child.getTurnSnapshots());
894
1016
  const completion = child.getSubAgentCompletion();
895
1017
 
896
1018
  if (completion) {
897
- return { result: completion.result as T, trace };
1019
+ return { outcome: { ok: true, result: completion.result as T }, trace };
898
1020
  }
899
1021
 
900
- const finalMsg = [...trace]
901
- .reverse()
902
- .find((m) => m.role === 'assistant' && !m.toolCalls?.length && m.content?.trim());
903
- return { result: (finalMsg?.content ?? '') as string, trace };
1022
+ // No completion → the sub-agent's loop ended without calling its completion
1023
+ // tool. Surface the typed reason it recorded; default to 'max_iterations'
1024
+ // for the defensive case where the loop ended with no reason set (e.g. a
1025
+ // provider ignored forced tool use and returned text). The previous
1026
+ // final-text fallback is intentionally gone — sub-agents return a
1027
+ // structured outcome only, and the parent handler decides how to recover.
1028
+ const reason = child.getSubAgentFailure()?.reason ?? 'max_iterations';
1029
+ // Record under THIS (parent) driver's session so the failure lands on the
1030
+ // user-visible debug-log timeline — the child ran under its own session key.
1031
+ // This is also the only telemetry for the defensive default above, where the
1032
+ // child's loop ended without recording an explicit failure reason.
1033
+ recordMetaEvent(this.sessionKey, 'subagent.failed', { agent: name, reason });
1034
+ return { outcome: { ok: false, reason }, trace };
904
1035
  }
905
1036
 
906
1037
  /**
@@ -912,6 +1043,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
912
1043
 
913
1044
  this.busy = true;
914
1045
  this.subAgentCompletion = undefined;
1046
+ this.subAgentFailure = undefined;
915
1047
  this.turnStartedAt = Date.now();
916
1048
  recordMetaEvent(this.sessionKey, 'turn.start', {
917
1049
  phase: 'continueFromHistory',
@@ -1133,6 +1265,10 @@ export class ChatDriver extends EventTarget implements AiDriver {
1133
1265
  let iterations = 0;
1134
1266
  let malformedAttempts = 0;
1135
1267
  let emptyResponseAttempts = 0;
1268
+ // Bounded retries for transient throws while resolving the per-turn tool surface or
1269
+ // calling the provider. Without this, a single transient throw tears down the whole turn
1270
+ // and strands the agent's unflushed work behind an opaque error.
1271
+ let setupTransportAttempts = 0;
1136
1272
  // True only for the very first LLM call. Used to exclude the pending user message
1137
1273
  // from history (it is passed separately as currentInput). Must not be derived from
1138
1274
  // `iterations` because fold operations decrement iterations, which would incorrectly
@@ -1155,17 +1291,31 @@ export class ChatDriver extends EventTarget implements AiDriver {
1155
1291
  // forbidden when a factory is set, so the array form is always valid.
1156
1292
  // Sequential await is required — each iteration must see fresh values
1157
1293
  // before constructing the LLM request.
1158
- if (this.toolDefinitionsFactory) {
1159
- // oxlint-disable-next-line no-await-in-loop
1160
- this.toolDefinitions = await this.toolDefinitionsFactory(promptCtx);
1161
- }
1162
- // Same story for the handler-map factory: re-resolve so dispatch sees
1163
- // only the handlers valid for the current state, in lockstep with the
1164
- // tool definitions exposed above. Folds are forbidden when this is set,
1165
- // so the fold-mutation paths on `this.toolHandlers` are unreachable.
1166
- if (this.toolHandlersFactory) {
1167
- // oxlint-disable-next-line no-await-in-loop
1168
- this.toolHandlers = await this.toolHandlersFactory(promptCtx);
1294
+ // A transient throw while building the tool surface should retry the iteration, not
1295
+ // tear down the whole turn and strand the agent's unflushed buffer behind an opaque
1296
+ // error. The handler-map factory re-resolves in lockstep so dispatch sees only the
1297
+ // handlers valid for the current state, in step with the tool definitions exposed
1298
+ // above. Folds are forbidden when either factory is set, so the fold-mutation paths
1299
+ // on `this.toolDefinitions` / `this.toolHandlers` are unreachable.
1300
+ try {
1301
+ if (this.toolDefinitionsFactory) {
1302
+ // oxlint-disable-next-line no-await-in-loop
1303
+ this.toolDefinitions = await this.toolDefinitionsFactory(promptCtx);
1304
+ }
1305
+ if (this.toolHandlersFactory) {
1306
+ // oxlint-disable-next-line no-await-in-loop
1307
+ this.toolHandlers = await this.toolHandlersFactory(promptCtx);
1308
+ }
1309
+ } catch (e) {
1310
+ setupTransportAttempts += 1;
1311
+ if (setupTransportAttempts < MAX_SETUP_TRANSPORT_RETRIES) {
1312
+ logger.warn(
1313
+ `ChatDriver: tool-surface resolution failed, retrying (${setupTransportAttempts}/${MAX_SETUP_TRANSPORT_RETRIES})`,
1314
+ );
1315
+ iterations -= 1;
1316
+ continue;
1317
+ }
1318
+ throw e;
1169
1319
  }
1170
1320
 
1171
1321
  // Record everything advertised this turn so the unknown-tool path can tell
@@ -1227,6 +1377,11 @@ export class ChatDriver extends EventTarget implements AiDriver {
1227
1377
  // Strip fold-only properties (foldEvent, foldPath) before sending to provider
1228
1378
  tools: this.toolDefinitions.length ? this.toolDefinitions : undefined,
1229
1379
  attachments: attachmentsForCall,
1380
+ // Sub-agents must finish by calling a tool (their completion tool), never
1381
+ // by emitting a free-text turn — force tool use so the provider can't
1382
+ // return a bare text answer. Top-level agents stay on the default 'auto'.
1383
+ // (Transports no-op the force when no tools are advertised.)
1384
+ toolChoice: this.isSubAgent ? 'required' : undefined,
1230
1385
  };
1231
1386
 
1232
1387
  // Resolve the active provider for this turn. Static names were validated
@@ -1262,14 +1417,31 @@ export class ChatDriver extends EventTarget implements AiDriver {
1262
1417
  provider: this.lastResolvedProviderName,
1263
1418
  attempts: malformedAttempts,
1264
1419
  finishMessage: e.finishMessage,
1420
+ isSubAgent: this.isSubAgent,
1265
1421
  });
1266
- this.appendToHistory({
1267
- role: 'assistant',
1268
- content:
1269
- 'While working on your request, I repeatedly called my tools incorrectly. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
1270
- });
1422
+ if (this.isSubAgent) {
1423
+ // Bubble a typed failure to the parent instead of speaking to the user.
1424
+ this.failSubAgent('malformed_tool_call');
1425
+ } else {
1426
+ this.appendToHistory({
1427
+ role: 'assistant',
1428
+ content:
1429
+ 'While working on your request, I repeatedly called my tools incorrectly. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
1430
+ });
1431
+ }
1271
1432
  return { reason: 'done' };
1272
1433
  }
1434
+ // A transient provider/transport error should retry the SAME iteration a bounded
1435
+ // number of times rather than tearing down the whole turn (which strands the
1436
+ // agent's unflushed buffer behind an opaque error message).
1437
+ setupTransportAttempts += 1;
1438
+ if (setupTransportAttempts < MAX_SETUP_TRANSPORT_RETRIES) {
1439
+ logger.warn(
1440
+ `ChatDriver: provider/transport error, retrying (${setupTransportAttempts}/${MAX_SETUP_TRANSPORT_RETRIES})`,
1441
+ );
1442
+ iterations -= 1;
1443
+ continue;
1444
+ }
1273
1445
  throw e;
1274
1446
  }
1275
1447
 
@@ -1296,12 +1468,17 @@ export class ChatDriver extends EventTarget implements AiDriver {
1296
1468
  agent: this.activeAgentName,
1297
1469
  provider: this.lastResolvedProviderName,
1298
1470
  attempts: emptyResponseAttempts,
1471
+ isSubAgent: this.isSubAgent,
1299
1472
  });
1300
- this.appendToHistory({
1301
- role: 'assistant',
1302
- content:
1303
- 'While working on your request, I repeatedly generated a blank response. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
1304
- });
1473
+ if (this.isSubAgent) {
1474
+ this.failSubAgent('empty_response');
1475
+ } else {
1476
+ this.appendToHistory({
1477
+ role: 'assistant',
1478
+ content:
1479
+ 'While working on your request, I repeatedly generated a blank response. This often works on a second try — would you like me to try again? If it happens again, try breaking your request into smaller steps.',
1480
+ });
1481
+ }
1305
1482
  return { reason: 'done' };
1306
1483
  } else if (isThinkingStep) {
1307
1484
  this.appendToHistory({ ...response, toolCalls: undefined, thinking: true });
@@ -1310,6 +1487,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
1310
1487
  this.appendToHistory(response);
1311
1488
  }
1312
1489
 
1490
+ // Reset retry budgets on any productive (non-empty) response, so the caps mean
1491
+ // "N CONSECUTIVE failures" not "N total per turn".
1492
+ emptyResponseAttempts = 0;
1493
+ malformedAttempts = 0;
1494
+ setupTransportAttempts = 0;
1495
+
1313
1496
  if (!response.toolCalls?.length) {
1314
1497
  break;
1315
1498
  }
@@ -1406,8 +1589,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1406
1589
  // or an exclusive fold is hiding it) rather than hallucinated — a
1407
1590
  // distinction worth making, because the model should stop retrying
1408
1591
  // a retired tool rather than treat the failure as a typo. Stale
1409
- // calls still count toward the same unknown-tool limit (loop
1410
- // protection); only the guidance and telemetry differ.
1592
+ // calls still trip loop protection, but at a higher ceiling than
1593
+ // hallucinated tools (see below) they are self-correcting, so the
1594
+ // guidance, telemetry, and limit differ.
1411
1595
  if (this.everSeenToolNames.has(tc.name)) {
1412
1596
  this.consecutiveUnknownToolCalls += 1;
1413
1597
  const hidingFold = this.foldHidingTool(tc.name);
@@ -1415,12 +1599,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
1415
1599
  if (hidingFold) {
1416
1600
  content = `"${tc.name}" is not available while the "${hidingFold}" fold is open. Call close_${hidingFold} to return to the previous set of tools, then call ${tc.name}.`;
1417
1601
  logger.warn(
1418
- `ChatDriver: tool "${tc.name}" is hidden behind open fold "${hidingFold}" (${this.consecutiveUnknownToolCalls}/${DEFAULT_MAX_UNKNOWN_TOOL_CALLS})`,
1602
+ `ChatDriver: tool "${tc.name}" is hidden behind open fold "${hidingFold}" (${this.consecutiveUnknownToolCalls}/${MAX_STALE_TOOL_CALLS})`,
1419
1603
  );
1420
1604
  } else {
1421
1605
  content = `"${tc.name}" was available earlier but is not part of the current step — that step is complete, so do not call it again. Continue with the tools available now: ${Object.keys(this.toolHandlers).join(', ') || '(none)'}.`;
1422
1606
  logger.warn(
1423
- `ChatDriver: stale tool "${tc.name}" — advertised earlier this activation but retired in the current state (${this.consecutiveUnknownToolCalls}/${DEFAULT_MAX_UNKNOWN_TOOL_CALLS})`,
1607
+ `ChatDriver: stale tool "${tc.name}" — advertised earlier this activation but retired in the current state (${this.consecutiveUnknownToolCalls}/${MAX_STALE_TOOL_CALLS})`,
1424
1608
  );
1425
1609
  }
1426
1610
  recordMetaEvent(this.sessionKey, 'tool.unresolved', {
@@ -1429,14 +1613,14 @@ export class ChatDriver extends EventTarget implements AiDriver {
1429
1613
  kind: hidingFold ? 'fold-hidden' : 'stale',
1430
1614
  fold: hidingFold ?? undefined,
1431
1615
  consecutive: this.consecutiveUnknownToolCalls,
1432
- max: DEFAULT_MAX_UNKNOWN_TOOL_CALLS,
1616
+ max: MAX_STALE_TOOL_CALLS,
1433
1617
  });
1434
1618
  executedById.set(tc.id, { toolCallId: tc.id, content });
1435
1619
  unknownToolIds.add(tc.id);
1436
1620
  staleToolIds.add(tc.id);
1437
1621
  this.recentUnknownToolNames.add(tc.name);
1438
1622
  this.recentStaleToolNames.add(tc.name);
1439
- if (this.consecutiveUnknownToolCalls >= DEFAULT_MAX_UNKNOWN_TOOL_CALLS) {
1623
+ if (this.consecutiveUnknownToolCalls >= MAX_STALE_TOOL_CALLS) {
1440
1624
  hitUnknownToolLimit = true;
1441
1625
  }
1442
1626
  return;
@@ -1484,7 +1668,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
1484
1668
  });
1485
1669
  executedById.set(tc.id, {
1486
1670
  toolCallId: tc.id,
1487
- content: `Tool error: ${(e as Error).message}`,
1671
+ // Structured recovery hint so the model retries or routes around a tool
1672
+ // failure instead of apologising and giving up.
1673
+ content: `Tool error: ${(e as Error).message}\nRECOVERY: this tool failed once — you may retry it, or take a different valid action to make progress. Do NOT abandon the task, ask the user to rephrase, or claim you cannot make changes. If a planning tool failed, retry it or proceed with the information you already have.`,
1488
1674
  });
1489
1675
  anyRealToolExecuted = true; // treat errors as real work for fold op counting
1490
1676
  }
@@ -1595,12 +1781,17 @@ export class ChatDriver extends EventTarget implements AiDriver {
1595
1781
  staleTools,
1596
1782
  hallucinatedTools,
1597
1783
  availableTools: Object.keys(this.toolHandlers),
1784
+ isSubAgent: this.isSubAgent,
1598
1785
  });
1599
- this.appendToHistory({
1600
- role: 'assistant',
1601
- content:
1602
- "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue.",
1603
- });
1786
+ if (this.isSubAgent) {
1787
+ this.failSubAgent('unknown_tool_limit');
1788
+ } else {
1789
+ this.appendToHistory({
1790
+ role: 'assistant',
1791
+ content:
1792
+ "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue.",
1793
+ });
1794
+ }
1604
1795
  return { reason: 'done' };
1605
1796
  }
1606
1797
 
@@ -1627,12 +1818,17 @@ export class ChatDriver extends EventTarget implements AiDriver {
1627
1818
  provider: this.lastResolvedProviderName,
1628
1819
  iterations,
1629
1820
  limit: this.maxToolIterations,
1821
+ isSubAgent: this.isSubAgent,
1630
1822
  });
1631
- this.appendToHistory({
1632
- role: 'assistant',
1633
- content:
1634
- "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off.",
1635
- });
1823
+ if (this.isSubAgent) {
1824
+ this.failSubAgent('max_iterations');
1825
+ } else {
1826
+ this.appendToHistory({
1827
+ role: 'assistant',
1828
+ content:
1829
+ "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off.",
1830
+ });
1831
+ }
1636
1832
  }
1637
1833
 
1638
1834
  return { reason: 'done' };
package/src/main/main.ts CHANGED
@@ -1434,7 +1434,7 @@ export class FoundationAiAssistant extends GenesisElement {
1434
1434
  // prompt is still shown in full whenever it changes, so prompt evolution
1435
1435
  // stays visible.
1436
1436
  let lastFullPrompt: string | undefined;
1437
- let lastFullIndex = -1;
1437
+ let lastFullIndex = '';
1438
1438
  const turns = (this.driver?.getTurnSnapshots?.() ?? []).map((t) => {
1439
1439
  let { systemPrompt } = t;
1440
1440
  if (systemPrompt != null && systemPrompt === lastFullPrompt) {
@@ -46,6 +46,7 @@ export type MetaEventType =
46
46
  | 'turn.error'
47
47
  | 'tool.failed'
48
48
  | 'tool.unresolved'
49
+ | 'subagent.failed'
49
50
  // Routing / providers
50
51
  | 'agent.handoff'
51
52
  | 'agent.pinned'
@@ -83,6 +84,7 @@ export type MetaEventImportance = 'high' | 'normal' | 'low';
83
84
  export const META_EVENT_IMPORTANCE: Record<MetaEventType, MetaEventImportance> = {
84
85
  'turn.error': 'high',
85
86
  'tool.failed': 'high',
87
+ 'subagent.failed': 'high',
86
88
  'file.read-failed': 'high',
87
89
  'suggestions.failed': 'high',
88
90
  'context.threshold-crossed': 'high',
@@ -241,7 +243,7 @@ export const DEBUG_LOG_README: readonly string[] = [
241
243
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
242
244
  '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
243
245
  "kind:'message' — the conversation. `role` is user/assistant/tool/system-event; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message usage.",
244
- "kind:'turn' — one LLM call. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
246
+ "kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
245
247
  "kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
246
248
  "kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
247
249
  "Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",