@leanandmean/coding-agent 0.74.1-scramjet.20 → 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.
- package/dist/core/agent-session.d.ts +22 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +159 -6
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/types.d.ts +16 -2
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/docs/extensions.md +16 -0
- package/docs/sdk.md +5 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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
|
-
|
|
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() {
|