@leanandmean/coding-agent 0.74.1-scramjet.19 → 0.74.1-scramjet.21

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.
@@ -14,6 +14,9 @@
14
14
  */
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { basename, dirname, resolve } from "node:path";
17
+ // SCRAMJET-DIVERGENCE: provider-safe harness tool-call id generation for the persisted-settlement
18
+ // acknowledgement key (#341).
19
+ import { generateHarnessToolCallId } from "@leanandmean/agent";
17
20
  import { clampThinkingLevel, cleanupSessionResources, flattenSystemPrompt, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@leanandmean/ai";
18
21
  import { theme } from "../modes/interactive/theme/theme.js";
19
22
  import { stripFrontmatter } from "../utils/frontmatter.js";
@@ -53,9 +56,6 @@ export function parseSkillBlock(text) {
53
56
  // ============================================================================
54
57
  /** Standard thinking levels */
55
58
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
56
- // ============================================================================
57
- // AgentSession Class
58
- // ============================================================================
59
59
  export class AgentSession {
60
60
  agent;
61
61
  sessionManager;
@@ -65,6 +65,11 @@ export class AgentSession {
65
65
  _unsubscribeAgent;
66
66
  _eventListeners = [];
67
67
  _agentEventQueue = Promise.resolve();
68
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement state (#341). `_disposed` gates post-
69
+ // teardown invocations; the map keys each in-flight invocation's tool-call id to its persistence
70
+ // acknowledgement so `_processAgentEvent` can settle it after the matching tool-result is persisted.
71
+ _disposed = false;
72
+ _harnessPersistenceAcks = new Map();
68
73
  /** Tracks pending steering messages for UI display. Removed when delivered. */
69
74
  _steeringMessages = [];
70
75
  /** Tracks pending follow-up messages for UI display. Removed when delivered. */
@@ -252,7 +257,7 @@ export class AgentSession {
252
257
  // _processAgentEvent, slow earlier queued events can delay agent_end processing
253
258
  // and waitForRetry() can miss the in-flight retry.
254
259
  this._createRetryPromiseForAgentEnd(event);
255
- this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => this._processAgentEvent(event));
260
+ this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEventTracked(event), () => this._processAgentEventTracked(event));
256
261
  // Keep queue alive if an event handler fails
257
262
  this._agentEventQueue.catch(() => { });
258
263
  };
@@ -281,6 +286,68 @@ export class AgentSession {
281
286
  }
282
287
  return undefined;
283
288
  }
289
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Wraps queue-side event processing
290
+ // so a harness-tool invocation's persistence acknowledgement resolves only after the matching
291
+ // tool-result message_end has been fully processed and persisted, and rejects if processing any of
292
+ // that invocation's events throws. Rethrows to preserve the queue's existing keep-processing
293
+ // behavior (the caller attaches `.catch(() => {})`).
294
+ async _processAgentEventTracked(event) {
295
+ const ackId = this._harnessAckIdForEvent(event);
296
+ const ack = ackId !== undefined ? this._harnessPersistenceAcks.get(ackId) : undefined;
297
+ // Decide from the ORIGINAL event, before _processAgentEvent runs, whether this is the
298
+ // ack-resolving tool-result: a message_end hook can replace event.message in place
299
+ // (_replaceMessageInPlace), so re-reading role/toolCallId after the await could miss the match
300
+ // and strand the acknowledgement.
301
+ const resolvesAck = ack !== undefined &&
302
+ event.type === "message_end" &&
303
+ event.message.role === "toolResult" &&
304
+ event.message.toolCallId === ackId;
305
+ try {
306
+ await this._processAgentEvent(event);
307
+ }
308
+ catch (err) {
309
+ if (ack) {
310
+ ack.reject(err instanceof Error ? err : new Error(String(err)));
311
+ }
312
+ throw err;
313
+ }
314
+ // Resolving here counts as "persisted" only because SessionManager persistence is synchronous
315
+ // (appendFileSync): _processAgentEvent has already written the tool-result row by the time it
316
+ // returns. If _persist ever becomes async, this would resolve before persistence completes.
317
+ if (resolvesAck) {
318
+ ack?.resolve();
319
+ }
320
+ }
321
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). The tool-call id an event belongs
322
+ // to for acknowledgement tracking — the synthetic assistant tool-call, the `tool_execution_*`
323
+ // events, or the tool-result message. Returns undefined for events unrelated to a pending harness
324
+ // invocation; a returned id that has no pending acknowledgement (an ordinary model tool call)
325
+ // resolves to no-op at the call site.
326
+ _harnessAckIdForEvent(event) {
327
+ switch (event.type) {
328
+ case "tool_execution_start":
329
+ case "tool_execution_update":
330
+ case "tool_execution_end":
331
+ return event.toolCallId;
332
+ case "message_start":
333
+ case "message_end": {
334
+ const message = event.message;
335
+ if (message.role === "toolResult") {
336
+ return message.toolCallId;
337
+ }
338
+ if (message.role === "assistant") {
339
+ for (const block of message.content) {
340
+ if (block.type === "toolCall" && this._harnessPersistenceAcks.has(block.id)) {
341
+ return block.id;
342
+ }
343
+ }
344
+ }
345
+ return undefined;
346
+ }
347
+ default:
348
+ return undefined;
349
+ }
350
+ }
284
351
  async _processAgentEvent(event) {
285
352
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
286
353
  // This ensures the UI sees the updated queue state
@@ -520,7 +587,22 @@ export class AgentSession {
520
587
  * Call this when completely done with the session.
521
588
  */
522
589
  dispose() {
590
+ // SCRAMJET-DIVERGENCE: idempotent disposal that revokes in-flight harness-tool invocations (#341).
591
+ // A second call must not re-reject already-settled acknowledgements or re-run teardown.
592
+ if (this._disposed)
593
+ return;
594
+ this._disposed = true;
523
595
  this._extensionRunner.invalidate("This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().");
596
+ // Reject pending persistence acknowledgements before revoking Agent-side invocations, so a caller
597
+ // awaiting invokeHarnessTool sees rejection rather than a promise that never settles. Already-
598
+ // running tool work may finish afterward; teardown revokes the old-session guarantee but cannot
599
+ // roll back arbitrary side effects.
600
+ const disposeError = new Error("AgentSession disposed before the harness tool invocation settled.");
601
+ for (const ack of this._harnessPersistenceAcks.values()) {
602
+ ack.reject(disposeError);
603
+ }
604
+ this._harnessPersistenceAcks.clear();
605
+ this.agent.rejectUnsettledHarnessTools(disposeError);
524
606
  this._disconnectFromAgent();
525
607
  this._eventListeners = [];
526
608
  cleanupSessionResources(this.sessionId);
@@ -601,7 +683,7 @@ export class AgentSession {
601
683
  // Rebuild base system prompt with new tool set
602
684
  this._rebuildSystemPrompt(validToolNames);
603
685
  }
604
- // SCRAMJET-DIVERGENCE: harness-tool invocation (#244).
686
+ // SCRAMJET-DIVERGENCE: harness-tool invocation (#244), persisted-settlement boundary (#341).
605
687
  /**
606
688
  * Execute a registered tool as a harness-originated call (not requested by the model).
607
689
  *
@@ -613,13 +695,84 @@ export class AgentSession {
613
695
  * run/turn framing. The idle-vs-mid-run branch lives in `runHarnessTool` (keyed on the agent's
614
696
  * active run, which tracks `isStreaming`): idle calls execute immediately, mid-run calls queue
615
697
  * and drain before the next intra-run LLM call.
698
+ *
699
+ * **Persisted settlement (#341).** This is a strictly stronger boundary than
700
+ * {@link Agent.runHarnessTool}: the returned promise resolves only after (1) the Agent-core
701
+ * pipeline fulfills *and* (2) `_processAgentEvent` has persisted the matching tool-result
702
+ * `message_end` via `SessionManager.appendMessage`. Because the event queue preserves order,
703
+ * that also proves the preceding synthetic assistant tool-call message was persisted first. It
704
+ * rejects on Agent-core failure, on failure to process/persist a matching event, on disposal
705
+ * before settlement, on a post-disposal call, on an unknown tool, or on a duplicate explicit
706
+ * tool-call id that is already pending. **Rejection means the promised pipeline did not complete —
707
+ * it does not prove artifact absence** (Agent state and persistence may have partially or fully
708
+ * completed before a late failure), so callers must not blindly retry. A tool's own `execute()`
709
+ * error is *not* a rejection: it becomes a normal `isError: true` result and the promise resolves
710
+ * after that error-result pipeline is persisted.
711
+ *
712
+ * **Reentrancy.** As with `runHarnessTool`, do not `await` this from work whose return is required
713
+ * for the Agent to reach the drain point or for the event queue to persist the matching result.
616
714
  */
617
715
  async invokeHarnessTool(name, args, options) {
716
+ if (this._disposed) {
717
+ throw new Error(`Cannot invoke harness tool "${name}": the session has been disposed.`);
718
+ }
618
719
  const tool = this._toolRegistry.get(name);
619
720
  if (!tool) {
620
721
  throw new Error(`Cannot invoke harness tool "${name}": no tool with that name is registered.`);
621
722
  }
622
- await this.agent.runHarnessTool(tool, args, options);
723
+ const toolCallId = this._allocateHarnessToolCallId(options?.toolCallId);
724
+ // Register the acknowledgement before Agent execution can emit events, so the queue-side handler
725
+ // always finds it. Await Agent execution and persistence concurrently: Agent-core failure or a
726
+ // persistence failure rejects, and the tool-call-id-keyed handler resolves after the matching
727
+ // tool-result is persisted.
728
+ const ack = this._registerHarnessPersistenceAck(toolCallId);
729
+ try {
730
+ await Promise.all([this.agent.runHarnessTool(tool, args, { toolCallId }), ack.promise]);
731
+ }
732
+ finally {
733
+ // Identity-checked cleanup: only drop the entry if it is still this invocation's ack (disposal
734
+ // may have cleared the map, and a later same-id invocation must not have its entry removed).
735
+ if (this._harnessPersistenceAcks.get(toolCallId) === ack) {
736
+ this._harnessPersistenceAcks.delete(toolCallId);
737
+ }
738
+ }
739
+ }
740
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Allocate the tool-call id used as
741
+ // the acknowledgement key: reject a duplicate explicit id that is still pending, retry a generated
742
+ // id on the (astronomically unlikely) collision with a pending one.
743
+ _allocateHarnessToolCallId(explicit) {
744
+ if (explicit !== undefined) {
745
+ // Validate at the public trust boundary (invokeHarnessTool is on ExtensionAPI): an explicit
746
+ // id flows straight into the synthetic assistant message and on to the provider, so it must
747
+ // meet the provider-safe shape generateHarnessToolCallId guarantees (Anthropic's
748
+ // ^[a-zA-Z0-9_-]+$, bounded length).
749
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(explicit)) {
750
+ throw new Error(`Cannot invoke harness tool with tool-call id "${explicit}": ids must be 1-64 characters of [a-zA-Z0-9_-].`);
751
+ }
752
+ if (this._harnessPersistenceAcks.has(explicit)) {
753
+ throw new Error(`Cannot invoke harness tool with tool-call id "${explicit}": an invocation with that id is already pending.`);
754
+ }
755
+ return explicit;
756
+ }
757
+ let id = generateHarnessToolCallId();
758
+ while (this._harnessPersistenceAcks.has(id)) {
759
+ id = generateHarnessToolCallId();
760
+ }
761
+ return id;
762
+ }
763
+ // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Register a deferred acknowledgement
764
+ // keyed by tool-call id. The caller awaits `ack.promise` inside the same synchronous frame that
765
+ // registers it, so a synchronous disposal rejection can never strand it as an unhandled rejection.
766
+ _registerHarnessPersistenceAck(toolCallId) {
767
+ let resolve;
768
+ let reject;
769
+ const promise = new Promise((res, rej) => {
770
+ resolve = res;
771
+ reject = rej;
772
+ });
773
+ const ack = { promise, resolve, reject };
774
+ this._harnessPersistenceAcks.set(toolCallId, ack);
775
+ return ack;
623
776
  }
624
777
  /** Whether compaction or branch summarization is currently running */
625
778
  get isCompacting() {
@@ -705,6 +858,7 @@ export class AgentSession {
705
858
  const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
706
859
  const loadedSkills = this._resourceLoader.getSkills().skills;
707
860
  const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
861
+ // SCRAMJET-DIVERGENCE: pass session file path for volatile environment fact (issue 348)
708
862
  this._baseSystemPromptOptions = {
709
863
  cwd: this._cwd,
710
864
  skills: loadedSkills,
@@ -714,6 +868,7 @@ export class AgentSession {
714
868
  selectedTools: validToolNames,
715
869
  toolSnippets,
716
870
  promptGuidelines,
871
+ sessionFile: this.sessionManager.getSessionFile(),
717
872
  };
718
873
  this._baseSystemPromptSections = buildSystemPromptSections(this._baseSystemPromptOptions);
719
874
  this.agent.state.systemPrompt = this._baseSystemPromptSections.slice();