@code-yeongyu/senpi-agent-core 2026.10.18-1 → 2026.10.19

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.
@@ -105,19 +105,29 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
105
105
  let explicitContinuation = false;
106
106
  // Messages from `prepareNextTurn`, appended before the next provider request.
107
107
  let preparedMessages = [];
108
+ // Which queue the current pendingMessages were drained from, so consumption is committed against it.
109
+ let pendingSource = "steering";
110
+ const drainSteering = async () => {
111
+ pendingSource = "steering";
112
+ return (await config.getSteeringMessages?.()) || [];
113
+ };
114
+ const drainFollowUp = async () => {
115
+ pendingSource = "followUp";
116
+ return (await config.getFollowUpMessages?.()) || [];
117
+ };
108
118
  const refreshTerminatingQueueDrain = async () => {
109
119
  if (!drainedTerminatingQueue || !config.restorePendingMessages)
110
120
  return;
111
121
  await config.restorePendingMessages(drainedTerminatingQueue, pendingMessages);
112
- pendingMessages = (await config.getSteeringMessages?.()) || [];
122
+ pendingMessages = await drainSteering();
113
123
  drainedTerminatingQueue = pendingMessages.length > 0 ? "steering" : undefined;
114
124
  if (pendingMessages.length === 0) {
115
- pendingMessages = (await config.getFollowUpMessages?.()) || [];
125
+ pendingMessages = await drainFollowUp();
116
126
  drainedTerminatingQueue = pendingMessages.length > 0 ? "followUp" : undefined;
117
127
  }
118
128
  };
119
129
  // Check for steering messages at start (user may have typed while waiting)
120
- let pendingMessages = (await config.getSteeringMessages?.()) || [];
130
+ let pendingMessages = await drainSteering();
121
131
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
122
132
  while (true) {
123
133
  let hasMoreToolCalls = true;
@@ -140,6 +150,17 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
140
150
  }
141
151
  drainedTerminatingQueue = undefined;
142
152
  }
153
+ // Commit the queued messages' consumption before any of them reaches the context or a provider;
154
+ // a rejected commit restores them and fails this attempt.
155
+ if (pendingMessages.length > 0 && config.commitQueuedConsumption) {
156
+ try {
157
+ await config.commitQueuedConsumption(pendingSource, pendingMessages);
158
+ }
159
+ catch (error) {
160
+ await config.restorePendingMessages?.(pendingSource, pendingMessages);
161
+ throw error;
162
+ }
163
+ }
143
164
  // Process prepared and queued messages before the next assistant response.
144
165
  for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages], config.model)) {
145
166
  await emit({ type: "message_start", message });
@@ -224,11 +245,11 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
224
245
  }
225
246
  explicitContinuation = decision?.action === "continue";
226
247
  if (toolBatchTerminated) {
227
- pendingMessages = (await config.getSteeringMessages?.()) || [];
248
+ pendingMessages = await drainSteering();
228
249
  if (pendingMessages.length > 0)
229
250
  drainedTerminatingQueue = "steering";
230
251
  if (pendingMessages.length === 0) {
231
- pendingMessages = (await config.getFollowUpMessages?.()) || [];
252
+ pendingMessages = await drainFollowUp();
232
253
  if (pendingMessages.length > 0)
233
254
  drainedTerminatingQueue = "followUp";
234
255
  }
@@ -272,14 +293,14 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
272
293
  drainedTerminatingQueue = undefined;
273
294
  }
274
295
  if (!toolBatchTerminated) {
275
- pendingMessages = (await config.getSteeringMessages?.()) || [];
296
+ pendingMessages = await drainSteering();
276
297
  }
277
298
  if (hasMoreToolCalls || pendingMessages.length > 0) {
278
299
  explicitContinuation = false;
279
300
  }
280
301
  }
281
302
  // Agent would stop here. Check for follow-up messages.
282
- const followUpMessages = (await config.getFollowUpMessages?.()) || [];
303
+ const followUpMessages = await drainFollowUp();
283
304
  if (followUpMessages.length > 0) {
284
305
  // Set as pending so inner loop processes them
285
306
  explicitContinuation = false;
package/dist/agent.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface AgentOptions {
24
24
  prepareNextTurnWithContext?: (context: PrepareNextTurnContext, signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
25
25
  steeringMode?: QueueMode;
26
26
  followUpMode?: QueueMode;
27
+ /** Lets the owner of queued input commit its consumption before the loop sends it (senpi#3549). */
28
+ queuedInputPort?: QueuedInputPort;
27
29
  sessionId?: string;
28
30
  thinkingBudgets?: ThinkingBudgets;
29
31
  transport?: Transport;
@@ -37,6 +39,26 @@ export interface AgentOptions {
37
39
  /** Cursor exec-channel tool handlers; see {@link AgentLoopConfig.cursorExecHandlers}. */
38
40
  cursorExecHandlers?: AgentLoopConfig["cursorExecHandlers"];
39
41
  }
42
+ /**
43
+ * Claimed messages going back to their queue. Both answers are false once a clear landed after the claim:
44
+ * `restorable` asks before anything is recorded, `restore` puts the messages back only while that still holds.
45
+ */
46
+ export interface QueuedGiveBack {
47
+ restorable(): boolean;
48
+ restore(): boolean;
49
+ }
50
+ /** The owner side of the steering and follow-up queues; every drain, restore and consumption goes through it. */
51
+ export interface QueuedInputPort {
52
+ /** Runs `drain` (the native queue's drain) under the owner's gate and returns what it may hand to a turn. */
53
+ claim(queue: "steering" | "followUp", drain: () => AgentMessage[]): Promise<AgentMessage[]>;
54
+ /** Gives claimed messages back under the owner's gate; a clear that landed after the claim wins. */
55
+ release(queue: "steering" | "followUp", messages: AgentMessage[], giveBack: QueuedGiveBack): Promise<void>;
56
+ /**
57
+ * Called with drained queued messages before any of them enters the context or a provider request.
58
+ * Resolve once their consumption is durable; a rejection restores them and fails this attempt.
59
+ */
60
+ commitConsumption(queue: "steering" | "followUp", messages: AgentMessage[]): Promise<void>;
61
+ }
40
62
  export interface AgentContinuationOptions {
41
63
  /** Keep queued steering and follow-up input out of the continuation's first provider request only. */
42
64
  deferQueuedMessages?: boolean;
@@ -66,6 +88,7 @@ export declare class Agent {
66
88
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
67
89
  afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
68
90
  finishTurn?: FinishTurn;
91
+ queuedInputPort?: QueuedInputPort;
69
92
  prepareRequest?: PrepareRequest;
70
93
  prepareNextTurn?: (signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
71
94
  prepareNextTurnWithContext?: (context: PrepareNextTurnContext, signal?: AbortSignal) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
@@ -173,6 +196,18 @@ export declare class Agent {
173
196
  continue(options?: AgentContinuationOptions): Promise<void>;
174
197
  private normalizePromptInput;
175
198
  private runPromptMessages;
199
+ /**
200
+ * Starts a run from one queue. Without a port the queue is drained now, as before; with one, the drain is
201
+ * claimed and its consumption committed inside the run, before any of it reaches the context.
202
+ */
203
+ private runQueuedMessages;
204
+ /**
205
+ * Takes one queue's messages for a run: a plain drain without a port; with one, a claim whose consumption is
206
+ * committed before the caller puts anything into the context, released again if that commit fails.
207
+ */
208
+ private takeQueuedMessages;
209
+ /** The one give-back every queue exit uses: a clear since `generation` (read at the claim) wins. */
210
+ private giveBack;
176
211
  private runContinuation;
177
212
  private createContextSnapshot;
178
213
  private createLoopConfig;
package/dist/agent.js CHANGED
@@ -148,6 +148,7 @@ export class Agent {
148
148
  this.beforeToolCall = runtimeOptions.beforeToolCall;
149
149
  this.afterToolCall = runtimeOptions.afterToolCall;
150
150
  this.finishTurn = runtimeOptions.finishTurn;
151
+ this.queuedInputPort = runtimeOptions.queuedInputPort;
151
152
  this.prepareRequest = runtimeOptions.prepareRequest;
152
153
  this.prepareNextTurn = runtimeOptions.prepareNextTurn;
153
154
  this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext;
@@ -303,18 +304,16 @@ export class Agent {
303
304
  await this.continue(options);
304
305
  return;
305
306
  }
306
- const queuedSteering = this.steeringQueue.drain();
307
- if (queuedSteering.length > 0) {
308
- await this.runPromptMessages(queuedSteering, {
307
+ if (this.steeringQueue.hasItems()) {
308
+ await this.runQueuedMessages("steering", {
309
309
  skipInitialSteeringPoll: true,
310
310
  initialRequestTimeoutMs: options.timeoutMs,
311
311
  initialRequestStreamStartTimeoutMs: options.streamStartTimeoutMs,
312
312
  });
313
313
  return;
314
314
  }
315
- const queuedFollowUps = this.followUpQueue.drain();
316
- if (queuedFollowUps.length > 0) {
317
- await this.runPromptMessages(queuedFollowUps, {
315
+ if (this.followUpQueue.hasItems()) {
316
+ await this.runQueuedMessages("followUp", {
318
317
  initialRequestTimeoutMs: options.timeoutMs,
319
318
  initialRequestStreamStartTimeoutMs: options.streamStartTimeoutMs,
320
319
  });
@@ -336,18 +335,16 @@ export class Agent {
336
335
  throw new Error("No messages to continue from");
337
336
  }
338
337
  if (lastMessage.role === "assistant") {
339
- const queuedSteering = this.steeringQueue.drain();
340
- if (queuedSteering.length > 0) {
341
- await this.runPromptMessages(queuedSteering, {
338
+ if (this.steeringQueue.hasItems()) {
339
+ await this.runQueuedMessages("steering", {
342
340
  skipInitialSteeringPoll: true,
343
341
  initialRequestTimeoutMs: options.timeoutMs,
344
342
  initialRequestStreamStartTimeoutMs: options.streamStartTimeoutMs,
345
343
  });
346
344
  return;
347
345
  }
348
- const queuedFollowUps = this.followUpQueue.drain();
349
- if (queuedFollowUps.length > 0) {
350
- await this.runPromptMessages(queuedFollowUps, {
346
+ if (this.followUpQueue.hasItems()) {
347
+ await this.runQueuedMessages("followUp", {
351
348
  initialRequestTimeoutMs: options.timeoutMs,
352
349
  initialRequestStreamStartTimeoutMs: options.streamStartTimeoutMs,
353
350
  });
@@ -375,6 +372,63 @@ export class Agent {
375
372
  await runAgentLoop(messages, this.createContextSnapshot(), this.createLoopConfig(options), (event) => this.processEvents(event), signal, this.streamFunction);
376
373
  });
377
374
  }
375
+ /**
376
+ * Starts a run from one queue. Without a port the queue is drained now, as before; with one, the drain is
377
+ * claimed and its consumption committed inside the run, before any of it reaches the context.
378
+ */
379
+ async runQueuedMessages(queue, options) {
380
+ const pendingQueue = queue === "steering" ? this.steeringQueue : this.followUpQueue;
381
+ const port = this.queuedInputPort;
382
+ if (!port) {
383
+ await this.runPromptMessages(pendingQueue.drain(), options);
384
+ return;
385
+ }
386
+ await this.runWithLifecycle(async (signal) => {
387
+ const messages = await this.takeQueuedMessages(queue);
388
+ if (messages.length === 0)
389
+ return;
390
+ await runAgentLoop(messages, this.createContextSnapshot(), this.createLoopConfig(options), (event) => this.processEvents(event), signal, this.streamFunction);
391
+ });
392
+ }
393
+ /**
394
+ * Takes one queue's messages for a run: a plain drain without a port; with one, a claim whose consumption is
395
+ * committed before the caller puts anything into the context, released again if that commit fails.
396
+ */
397
+ async takeQueuedMessages(queue) {
398
+ const pendingQueue = queue === "steering" ? this.steeringQueue : this.followUpQueue;
399
+ const port = this.queuedInputPort;
400
+ if (!port)
401
+ return pendingQueue.drain();
402
+ let generation = pendingQueue.getClearGeneration();
403
+ const messages = await port.claim(queue, () => {
404
+ generation = pendingQueue.getClearGeneration();
405
+ return pendingQueue.drain();
406
+ });
407
+ if (messages.length === 0)
408
+ return messages;
409
+ try {
410
+ await port.commitConsumption(queue, messages);
411
+ }
412
+ catch (error) {
413
+ await port.release(queue, messages, this.giveBack(queue, messages, generation));
414
+ throw error;
415
+ }
416
+ return messages;
417
+ }
418
+ /** The one give-back every queue exit uses: a clear since `generation` (read at the claim) wins. */
419
+ giveBack(queue, messages, generation) {
420
+ const pendingQueue = queue === "steering" ? this.steeringQueue : this.followUpQueue;
421
+ const restorable = () => pendingQueue.getClearGeneration() === generation;
422
+ return {
423
+ restorable,
424
+ restore: () => {
425
+ if (!restorable())
426
+ return false;
427
+ pendingQueue.prepend(messages);
428
+ return true;
429
+ },
430
+ };
431
+ }
378
432
  async runContinuation(options = {}) {
379
433
  await this.runWithLifecycle(async (signal) => {
380
434
  await runAgentLoopContinue(this.createContextSnapshot(), this.createLoopConfig({
@@ -397,6 +451,8 @@ export class Agent {
397
451
  let steeringQueueGeneration = this.steeringQueue.getClearGeneration();
398
452
  let followUpQueueGeneration = this.followUpQueue.getClearGeneration();
399
453
  const finishTurn = this.finishTurn;
454
+ const queuedInputPort = this.queuedInputPort;
455
+ const giveBack = (queue, messages) => this.giveBack(queue, messages, queue === "steering" ? steeringQueueGeneration : followUpQueueGeneration);
400
456
  const reasoning = this._state.reasoningBaseline ?? this._state.thinkingLevel;
401
457
  return {
402
458
  model: this._state.model,
@@ -447,23 +503,27 @@ export class Agent {
447
503
  skipInitialSteeringPoll = false;
448
504
  return [];
449
505
  }
450
- steeringQueueGeneration = this.steeringQueue.getClearGeneration();
451
- return this.steeringQueue.drain();
506
+ const drain = () => {
507
+ steeringQueueGeneration = this.steeringQueue.getClearGeneration();
508
+ return this.steeringQueue.drain();
509
+ };
510
+ return queuedInputPort ? queuedInputPort.claim("steering", drain) : drain();
452
511
  },
453
512
  getFollowUpMessages: async () => {
454
- followUpQueueGeneration = this.followUpQueue.getClearGeneration();
455
- return this.followUpQueue.drain();
513
+ const drain = () => {
514
+ followUpQueueGeneration = this.followUpQueue.getClearGeneration();
515
+ return this.followUpQueue.drain();
516
+ };
517
+ return queuedInputPort ? queuedInputPort.claim("followUp", drain) : drain();
456
518
  },
457
- restorePendingMessages: (queue, messages) => {
458
- if (queue === "steering") {
459
- if (this.steeringQueue.getClearGeneration() !== steeringQueueGeneration)
460
- return;
461
- this.steeringQueue.prepend(messages);
462
- return;
463
- }
464
- if (this.followUpQueue.getClearGeneration() !== followUpQueueGeneration)
465
- return;
466
- this.followUpQueue.prepend(messages);
519
+ commitQueuedConsumption: queuedInputPort
520
+ ? (queue, messages) => queuedInputPort.commitConsumption(queue, messages)
521
+ : undefined,
522
+ restorePendingMessages: async (queue, messages) => {
523
+ if (queuedInputPort)
524
+ await queuedInputPort.release(queue, messages, giveBack(queue, messages));
525
+ else
526
+ giveBack(queue, messages).restore();
467
527
  },
468
528
  };
469
529
  }
@@ -506,12 +566,12 @@ export class Agent {
506
566
  (lastMessage.stopReason !== "error" && lastMessage.stopReason !== "aborted"));
507
567
  }
508
568
  async runQueuedMessagesAfterAgentEnd(signal) {
509
- const queuedSteering = this.steeringQueue.drain();
569
+ const queuedSteering = this.steeringQueue.hasItems() ? await this.takeQueuedMessages("steering") : [];
510
570
  if (queuedSteering.length > 0) {
511
571
  await runAgentLoop(queuedSteering, this.createContextSnapshot(), this.createLoopConfig({ skipInitialSteeringPoll: true }), (event) => this.processEvents(event), signal, this.streamFunction);
512
572
  return;
513
573
  }
514
- const queuedFollowUps = this.followUpQueue.drain();
574
+ const queuedFollowUps = this.followUpQueue.hasItems() ? await this.takeQueuedMessages("followUp") : [];
515
575
  if (queuedFollowUps.length === 0) {
516
576
  return;
517
577
  }
package/dist/types.d.ts CHANGED
@@ -299,6 +299,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
299
299
  * Restored messages must be placed before messages queued after the drain.
300
300
  */
301
301
  restorePendingMessages?: (queue: "steering" | "followUp", messages: AgentMessage[]) => Promise<void> | void;
302
+ /**
303
+ * Called with the queued messages the loop is about to put into the context, after they were drained and
304
+ * before any of them is inserted or sent. A rejection keeps them out of this turn (they are restored), so a
305
+ * message is never seen by a provider without its consumption committed first (senpi#3549).
306
+ */
307
+ commitQueuedConsumption?: (queue: "steering" | "followUp", messages: AgentMessage[]) => Promise<void>;
302
308
  /**
303
309
  * Tool execution mode.
304
310
  * - "sequential": execute tool calls one by one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-agent-core",
3
- "version": "2026.10.18-1",
3
+ "version": "2026.10.19",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,8 +62,8 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@earendil-works/chord": "0.99.1",
65
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.18-1",
66
- "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.18-1",
65
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.19",
66
+ "@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.19",
67
67
  "diff": "9.0.0",
68
68
  "ignore": "7.0.9",
69
69
  "typebox": "1.3.34",