tinker-agent 1.10.1 → 2.0.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.0.0] - 2026-08-20
9
+
10
+ ### Changed
11
+
12
+ - Estimate image input locally with deterministic size buckets instead of a
13
+ provider-side token-estimation request. Images are normalized for orientation
14
+ and provider limits before estimation and upload, keeping preflight accounting
15
+ aligned with the payload sent to the model.
16
+
17
+ ### Removed
18
+
19
+ - Remove the `tokenEstimator` model-profile setting. Existing image profiles must
20
+ delete that field; sessions created under the previous image policy remain
21
+ inspectable but cannot be resumed for execution.
22
+
23
+ ## [1.11.0] - 2026-08-15
24
+
25
+ ### Added
26
+
27
+ - Allow text follow-up prompts to be queued while a turn is running. Follow-ups
28
+ are applied safely after a complete tool batch or handed off to a new turn
29
+ after a final response, while preserving canonical session history and TUI
30
+ continuity.
31
+
8
32
  ## [1.10.1] - 2026-08-15
9
33
 
10
34
  ### Added
@@ -199,7 +223,9 @@ All notable user-facing changes to Tinker are documented here. The project follo
199
223
  - First formal npm release under the `tinker-agent` package name with the `tinker`
200
224
  executable.
201
225
 
202
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.10.1...HEAD
226
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.0.0...HEAD
227
+ [2.0.0]: https://github.com/ishowshao/tinker/releases/tag/v2.0.0
228
+ [1.11.0]: https://github.com/ishowshao/tinker/releases/tag/v1.11.0
203
229
  [1.10.1]: https://github.com/ishowshao/tinker/releases/tag/v1.10.1
204
230
  [1.9.0]: https://github.com/ishowshao/tinker/releases/tag/v1.9.0
205
231
  [1.8.0]: https://github.com/ishowshao/tinker/releases/tag/v1.8.0
package/README.md CHANGED
@@ -194,7 +194,6 @@ Profile fields:
194
194
  | `includeReasoningContent` | No | JSON boolean | `false` | No | Replay provider reasoning_content in Chat Completions history; ignored by Responses. |
195
195
  | `stream` | No | JSON boolean | `true` | No | Use streaming transport for the selected model API. |
196
196
  | `inputModalities` | No | Normalized modality array | `["text"]` | No | Accepted model input modalities; normalizes to ["text"] or ["text", "image"]. |
197
- | `tokenEstimator` | With image | Object | — | Yes | Independent token estimator required for image profiles. |
198
197
 
199
198
  `reasoning` fields:
200
199
 
@@ -205,17 +204,6 @@ Profile fields:
205
204
 
206
205
  The optional `reasoning` object declares provider-specific effort values. Efforts must be unique non-whitespace strings, `reset` is reserved by the TUI command, and `defaultEffort` must appear in `supportedEfforts`. Omitting `reasoning` sends no effort parameter and disables `/reasoning` for that profile.
207
206
 
208
- `tokenEstimator` fields:
209
-
210
- | Field | Type / constraint | Secret | Description |
211
- | --- | --- | --- | --- |
212
- | `kind` | Literal `"moonshot-estimate-token-count-v1"` | No | Estimator protocol discriminator. |
213
- | `model` | Non-empty string | No | Estimator model name. |
214
- | `apiBase` | Non-empty string | No | Estimator API base URL. |
215
- | `apiKey` | Non-empty string | Yes | Estimator API credential. |
216
- | `timeoutMs` | Integer 1000–60000 | No | Estimator request timeout in milliseconds. |
217
- | `maxRetries` | Literal `0` | No | Estimator retry count; retries are disabled. |
218
-
219
207
  Text-only profile example:
220
208
 
221
209
  ```json
@@ -271,15 +259,7 @@ Image-capable profile example:
271
259
  "inputModalities": [
272
260
  "text",
273
261
  "image"
274
- ],
275
- "tokenEstimator": {
276
- "kind": "moonshot-estimate-token-count-v1",
277
- "model": "example-token-estimator",
278
- "apiBase": "https://estimator.example.com/v1",
279
- "apiKey": "your-estimator-api-key",
280
- "timeoutMs": 30000,
281
- "maxRetries": 0
282
- }
262
+ ]
283
263
  }
284
264
  }
285
265
  }
@@ -370,7 +350,7 @@ configured profile.
370
350
  ### Image Input
371
351
 
372
352
  Image attachment is enabled only for a profile whose `inputModalities` explicitly
373
- includes `image` and which supplies a valid `tokenEstimator`. In the interactive
353
+ includes `image`. In the interactive
374
354
  TUI, type `@` and select a file that is inside the workspace and visible to the
375
355
  workspace search rules. One-shot commands, clipboard image bytes, remote URLs, and
376
356
  files outside or ignored by the workspace search are not supported.
@@ -378,7 +358,11 @@ files outside or ignored by the workspace search are not supported.
378
358
  Tinker accepts PNG (not APNG), JPEG, and static WebP. It rejects GIF, animated
379
359
  WebP, and other formats. A message and provider request may contain at most eight
380
360
  images; each image may be at most 20 MiB, 4096 pixels on either edge, and 8,847,360
381
- pixels in total. See the
361
+ pixels in total. Provider requests preserve smaller images and proportionally
362
+ downscale larger images to a maximum 2048-pixel long edge. Context planning uses
363
+ fixed local token buckets derived from the materialized dimensions and performs no
364
+ independent token-estimator request. See the
365
+ [`image token bucket design`](docs/image-token-bucket-estimation-design.md) and
382
366
  [`multimodal image input design`](docs/multimodal-image-input-design.md) for the
383
367
  complete fixed policy and persistence contract.
384
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.10.1",
3
+ "version": "2.0.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -2,13 +2,8 @@ import type { ModelContextBudget } from "../model/model-context-profile";
2
2
  import type {
3
3
  ModelRequestOutput,
4
4
  ModelUsage,
5
- MaterializedModelRequest,
6
5
  PreparedModelRequest,
7
6
  } from "../model/model-client";
8
- import type {
9
- InputTokenEstimate,
10
- InputTokenEstimator,
11
- } from "../model/input-token-estimator";
12
7
  import {
13
8
  assertContextBudget,
14
9
  contextPressure,
@@ -68,7 +63,6 @@ export class ContextMeter {
68
63
  private anchor?: MeasuredContextAnchor;
69
64
  private lastProviderUsage?: ModelUsage;
70
65
  private calibrationIdentity?: string;
71
- private readonly providerEstimateCache = new Map<string, InputTokenEstimate>();
72
66
 
73
67
  constructor(
74
68
  private readonly budget: ModelContextBudget,
@@ -129,13 +123,14 @@ export class ContextMeter {
129
123
  let guardedDeltaTokens: number | undefined;
130
124
  if (anchor === undefined) {
131
125
  source = "estimated_full";
132
- usedInputTokens = Math.ceil(rawFullEstimate.totalTokens * correctionFactor);
126
+ usedInputTokens = guardedEstimate(rawFullEstimate, correctionFactor);
133
127
  } else {
134
128
  source = "measured_plus_estimated_delta";
135
- rawDeltaTokens = estimatePromptSegments(
129
+ const rawDelta = estimatePromptSegments(
136
130
  prepared.promptSegments.slice(anchor.segmentCount),
137
- ).totalTokens;
138
- guardedDeltaTokens = Math.ceil(rawDeltaTokens * correctionFactor);
131
+ );
132
+ rawDeltaTokens = rawDelta.totalTokens;
133
+ guardedDeltaTokens = guardedEstimate(rawDelta, correctionFactor);
139
134
  usedInputTokens = anchor.totalTokens + guardedDeltaTokens;
140
135
  }
141
136
 
@@ -217,62 +212,6 @@ export class ContextMeter {
217
212
  };
218
213
  }
219
214
 
220
- applyProviderEstimate(
221
- prepared: PreparedModelRequest,
222
- estimate: {
223
- inputTokens: number;
224
- coverage: "messages" | "full_request";
225
- },
226
- ): ContextUsageSnapshot {
227
- if (!Number.isSafeInteger(estimate.inputTokens) || estimate.inputTokens < 0) {
228
- throw new Error("Provider input estimate must be a non-negative safe integer.");
229
- }
230
- const local = this.measure(prepared);
231
- const measurement = this.measurements.get(prepared)!;
232
- const guardedTools =
233
- estimate.coverage === "messages"
234
- ? Math.ceil(
235
- measurement.rawFullEstimate.toolSchemaTokens * local.correctionFactor,
236
- )
237
- : 0;
238
- const providerGuarded = estimate.inputTokens + guardedTools;
239
- if (providerGuarded <= local.usedInputTokens) {
240
- return local;
241
- }
242
- const snapshot: ContextUsageSnapshot = {
243
- ...local,
244
- usedInputTokens: providerGuarded,
245
- source: "provider_estimated",
246
- pressure: contextPressure(providerGuarded, this.budget),
247
- };
248
- this.measurements.set(prepared, {
249
- rawFullEstimate: measurement.rawFullEstimate,
250
- snapshot,
251
- });
252
- return snapshot;
253
- }
254
-
255
- async estimateProviderInput(
256
- prepared: MaterializedModelRequest,
257
- estimator: InputTokenEstimator,
258
- options: { signal: AbortSignal },
259
- ): Promise<InputTokenEstimate> {
260
- options.signal.throwIfAborted();
261
- const key = providerEstimateCacheKey(
262
- prepared,
263
- estimator.compatibility.coverageVersion,
264
- );
265
- const cached = this.providerEstimateCache.get(key);
266
- if (cached !== undefined) {
267
- return cached;
268
- }
269
- const estimate = await estimator.estimate(prepared, options);
270
- options.signal.throwIfAborted();
271
- const frozen = Object.freeze({ ...estimate });
272
- this.providerEstimateCache.set(key, frozen);
273
- return frozen;
274
- }
275
-
276
215
  assertWithinBudget(snapshot: ContextUsageSnapshot): void {
277
216
  assertContextBudget({
278
217
  usedInputTokens: snapshot.usedInputTokens,
@@ -295,7 +234,6 @@ export class ContextMeter {
295
234
  this.lastProviderUsage = undefined;
296
235
  this.measurements = new WeakMap();
297
236
  this.calibration.clear();
298
- this.providerEstimateCache.clear();
299
237
  this.calibrationIdentity = nextIdentity;
300
238
  throw new Error(
301
239
  "Context revision changed the request configuration or tool schema.",
@@ -304,7 +242,6 @@ export class ContextMeter {
304
242
  this.anchor = undefined;
305
243
  this.lastProviderUsage = undefined;
306
244
  this.measurements = new WeakMap();
307
- this.providerEstimateCache.clear();
308
245
  this.calibrationIdentity = nextIdentity;
309
246
  }
310
247
 
@@ -315,7 +252,6 @@ export class ContextMeter {
315
252
  this.measurements = new WeakMap();
316
253
  this.calibration.clear();
317
254
  this.calibrationIdentity = undefined;
318
- this.providerEstimateCache.clear();
319
255
  }
320
256
 
321
257
  private usableAnchor(
@@ -330,10 +266,7 @@ export class ContextMeter {
330
266
  anchor.requestConfigHash !== prepared.requestConfigHash ||
331
267
  anchor.toolSchemaHash !== prepared.toolSchemaHash ||
332
268
  anchor.segmentCount > prepared.promptSegments.length ||
333
- prefixHashes[anchor.segmentCount] !== anchor.prefixHash ||
334
- prepared.promptSegments
335
- .slice(anchor.segmentCount)
336
- .some((segment) => (segment.media?.length ?? 0) > 0)
269
+ prefixHashes[anchor.segmentCount] !== anchor.prefixHash
337
270
  ) {
338
271
  this.anchor = undefined;
339
272
  return undefined;
@@ -359,18 +292,14 @@ export class ContextMeter {
359
292
  }
360
293
  }
361
294
 
362
- function providerEstimateCacheKey(
363
- prepared: PreparedModelRequest,
364
- coverageVersion: string,
365
- ): string {
366
- return [
367
- prepared.requestConfigHash,
368
- prepared.toolSchemaHash,
369
- lastPromptPrefixHash(
370
- promptPrefixHashes(prepared.requestConfigHash, prepared.promptSegments),
371
- ),
372
- coverageVersion,
373
- ].join(":");
295
+ function guardedEstimate(
296
+ breakdown: RawContextBreakdown,
297
+ correctionFactor: number,
298
+ ): number {
299
+ return (
300
+ Math.ceil(breakdown.textAndProtocolTokens * correctionFactor) +
301
+ breakdown.imageTokens
302
+ );
374
303
  }
375
304
 
376
305
  function assertMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
package/src/agent/loop.ts CHANGED
@@ -175,20 +175,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
175
175
  assetStore: input.assetStore,
176
176
  signal: input.signal,
177
177
  });
178
- if (preflight.source === "measured_plus_estimated_delta") {
179
- preflight = input.contextMeter.measure(request);
180
- } else {
181
- const estimator = input.model.inputTokenEstimator;
182
- if (estimator === undefined) {
183
- throw new Error("Image model request has no input token estimator.");
184
- }
185
- const estimate = await input.contextMeter.estimateProviderInput(
186
- request as MaterializedModelRequest,
187
- estimator,
188
- { signal: input.signal },
189
- );
190
- preflight = input.contextMeter.applyProviderEstimate(request, estimate);
191
- }
178
+ preflight = input.contextMeter.measure(request);
192
179
  input.contextMeter.assertWithinBudget(preflight);
193
180
  await input.runtimeSession.append({
194
181
  type: "context.usage.updated",
@@ -498,6 +485,10 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
498
485
  }
499
486
  }
500
487
 
488
+ await input.runtimeSession.applyQueuedSteering?.({
489
+ turn: input.turn,
490
+ ledger: input.ledger,
491
+ });
501
492
  await input.runtimeSession.append({
502
493
  type: "agent.iteration.finished",
503
494
  ...iteration,
@@ -139,6 +139,18 @@ export type AcceptedTurn = {
139
139
  readonly completion: Promise<RunAgentResult>;
140
140
  };
141
141
 
142
+ export type PromptSchedulerSnapshot = {
143
+ readonly state: "idle" | "running";
144
+ readonly activeTurnId?: TurnIdentity["turnId"];
145
+ readonly pendingCount: number;
146
+ };
147
+
148
+ export type QueueFollowUpResult = {
149
+ readonly kind: "queued";
150
+ readonly pendingCount: number;
151
+ readonly activeTurnId?: TurnIdentity["turnId"];
152
+ };
153
+
142
154
  export type SessionDisposeReason =
143
155
  | { type: "oneshot_complete" }
144
156
  | { type: "tui_exit" }
@@ -167,6 +179,9 @@ export type RuntimeSession = {
167
179
  ): Promise<void>;
168
180
  admitTurn(input: ExecuteTurnInput): Promise<AcceptedTurn>;
169
181
  executeTurn(input: ExecuteTurnInput): Promise<RunAgentResult>;
182
+ promptScheduler(): PromptSchedulerSnapshot;
183
+ subscribePromptScheduler(listener: () => void): () => void;
184
+ queueFollowUp(userMessage: UserMessage): QueueFollowUpResult;
170
185
  compactContext(): Promise<ContextCompactionResult>;
171
186
  retireContext(): Promise<ContextRetirementResult>;
172
187
  undoLatestFileMutationTurn(): Promise<TurnUndoResult>;
@@ -227,6 +242,10 @@ export type RuntimeSessionContext = {
227
242
  consumedThroughOrdinal: number;
228
243
  ledger: AgentTurnLedger;
229
244
  }): Promise<void>;
245
+ applyQueuedSteering?(input: {
246
+ turn: TurnIdentity;
247
+ ledger: AgentTurnLedger;
248
+ }): Promise<number>;
230
249
  };
231
250
 
232
251
  export type ContextSurfaceRefreshSummary = {
@@ -354,10 +373,18 @@ type RuntimeSessionState =
354
373
  | "disposed";
355
374
 
356
375
  type ActiveTurn = {
376
+ turn: TurnIdentity;
357
377
  controller: AbortController;
358
378
  completion: Promise<RunAgentResult>;
359
379
  };
360
380
 
381
+ type QueuedPrompt = {
382
+ readonly userMessage: UserMessage;
383
+ };
384
+
385
+ const MAX_QUEUED_PROMPTS = 8;
386
+ const MAX_QUEUED_PROMPT_TEXT_BYTES = 64 * 1024;
387
+
361
388
  type ActiveAdmission = {
362
389
  controller: AbortController;
363
390
  settled: Promise<void>;
@@ -415,6 +442,13 @@ class DefaultRuntimeSession implements RuntimeSession {
415
442
  private ledger?: SessionLedger;
416
443
  private activeAdmission?: ActiveAdmission;
417
444
  private activeTurn?: ActiveTurn;
445
+ private executionChainRunning = false;
446
+ private readonly queuedPrompts: QueuedPrompt[] = [];
447
+ private promptSchedulerSnapshot: PromptSchedulerSnapshot = Object.freeze({
448
+ state: "idle",
449
+ pendingCount: 0,
450
+ });
451
+ private readonly promptSchedulerListeners = new Set<() => void>();
418
452
  private activeContextRevision?: Promise<
419
453
  ContextCompactionResult | ContextRetirementResult
420
454
  >;
@@ -494,6 +528,7 @@ class DefaultRuntimeSession implements RuntimeSession {
494
528
  prepareModelDispatch: (dispatch) => this.prepareModelDispatch(dispatch),
495
529
  maintainContextAfterIteration: (maintenance) =>
496
530
  this.performActiveTurnContextMaintenance(maintenance),
531
+ applyQueuedSteering: (steering) => this.applyQueuedSteering(steering),
497
532
  };
498
533
  }
499
534
 
@@ -546,11 +581,6 @@ class DefaultRuntimeSession implements RuntimeSession {
546
581
  contextProfile: input.contextProfile,
547
582
  messageProtocol: input.modelClient.messageProtocol,
548
583
  inputModalities: input.modelClient.inputModalities ?? ["text"],
549
- ...(input.modelClient.inputTokenEstimator === undefined
550
- ? {}
551
- : {
552
- tokenEstimator: input.modelClient.inputTokenEstimator.compatibility,
553
- }),
554
584
  });
555
585
  if (input.selection.mode === "resume") {
556
586
  store.assertSessionCompatibility(compatibility);
@@ -1509,11 +1539,71 @@ class DefaultRuntimeSession implements RuntimeSession {
1509
1539
  }
1510
1540
  }
1511
1541
 
1542
+ promptScheduler(): PromptSchedulerSnapshot {
1543
+ return this.promptSchedulerSnapshot;
1544
+ }
1545
+
1546
+ subscribePromptScheduler(listener: () => void): () => void {
1547
+ this.promptSchedulerListeners.add(listener);
1548
+ return () => this.promptSchedulerListeners.delete(listener);
1549
+ }
1550
+
1551
+ queueFollowUp(userMessage: UserMessage): QueueFollowUpResult {
1552
+ if (!this.executionChainRunning) {
1553
+ throw new Error("Cannot queue a follow-up while no execution chain is running.");
1554
+ }
1555
+ validateUserMessage(userMessage);
1556
+ if (userMessage.attachments !== undefined) {
1557
+ throw new Error("Active-turn follow-ups do not support image attachments.");
1558
+ }
1559
+ if (this.queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
1560
+ throw new Error(`At most ${MAX_QUEUED_PROMPTS} follow-ups may be queued.`);
1561
+ }
1562
+ const queuedBytes = this.queuedPrompts.reduce(
1563
+ (total, entry) => total + Buffer.byteLength(entry.userMessage.content, "utf8"),
1564
+ 0,
1565
+ );
1566
+ const nextBytes = Buffer.byteLength(userMessage.content, "utf8");
1567
+ if (queuedBytes + nextBytes > MAX_QUEUED_PROMPT_TEXT_BYTES) {
1568
+ throw new Error("Queued follow-ups exceed the 64 KiB text limit.");
1569
+ }
1570
+ this.queuedPrompts.push({
1571
+ userMessage: Object.freeze({ ...userMessage }),
1572
+ });
1573
+ this.notifyPromptScheduler();
1574
+ return Object.freeze({
1575
+ kind: "queued",
1576
+ pendingCount: this.queuedPrompts.length,
1577
+ ...(this.activeTurn === undefined
1578
+ ? {}
1579
+ : { activeTurnId: this.activeTurn.turn.turnId }),
1580
+ });
1581
+ }
1582
+
1512
1583
  async executeTurn(input: ExecuteTurnInput): Promise<RunAgentResult> {
1513
1584
  return (await this.admitTurn(input)).completion;
1514
1585
  }
1515
1586
 
1516
1587
  async admitTurn(input: ExecuteTurnInput): Promise<AcceptedTurn> {
1588
+ if (this.executionChainRunning) {
1589
+ throw new Error(
1590
+ `Cannot execute a turn while RuntimeSession is ${this.state}; a prompt chain is already executing.`,
1591
+ );
1592
+ }
1593
+ this.executionChainRunning = true;
1594
+ this.notifyPromptScheduler();
1595
+ try {
1596
+ const accepted = await this.admitSingleTurn(input);
1597
+ const completion = this.continueExecutionChain(accepted.completion, input.signal);
1598
+ return Object.freeze({ ...accepted, completion });
1599
+ } catch (error) {
1600
+ this.executionChainRunning = false;
1601
+ this.notifyPromptScheduler();
1602
+ throw error;
1603
+ }
1604
+ }
1605
+
1606
+ private async admitSingleTurn(input: ExecuteTurnInput): Promise<AcceptedTurn> {
1517
1607
  if (this.state !== "ready") {
1518
1608
  throw new Error(`Cannot execute a turn while RuntimeSession is ${this.state}.`);
1519
1609
  }
@@ -1546,24 +1636,6 @@ class DefaultRuntimeSession implements RuntimeSession {
1546
1636
  { assetStore: this.assetStore, signal: controller.signal },
1547
1637
  );
1548
1638
  admissionSnapshot = this.contextMeter.measure(admissionPrepared);
1549
- if (
1550
- prepared.mediaOccurrenceCount > 0 &&
1551
- admissionSnapshot.source !== "measured_plus_estimated_delta"
1552
- ) {
1553
- const estimator = this.input.modelClient.inputTokenEstimator;
1554
- if (estimator === undefined) {
1555
- throw new Error("Image model request has no input token estimator.");
1556
- }
1557
- const estimate = await this.contextMeter.estimateProviderInput(
1558
- admissionPrepared,
1559
- estimator,
1560
- { signal: controller.signal },
1561
- );
1562
- admissionSnapshot = this.contextMeter.applyProviderEstimate(
1563
- admissionPrepared,
1564
- estimate,
1565
- );
1566
- }
1567
1639
  this.contextMeter.assertWithinBudget(admissionSnapshot);
1568
1640
  controller.signal.throwIfAborted();
1569
1641
  const turn = this.stageTurn(input.userMessage);
@@ -1586,7 +1658,8 @@ class DefaultRuntimeSession implements RuntimeSession {
1586
1658
  usage: admissionSnapshot,
1587
1659
  },
1588
1660
  });
1589
- this.activeTurn = { controller, completion };
1661
+ this.activeTurn = { turn, controller, completion };
1662
+ this.notifyPromptScheduler();
1590
1663
  return Object.freeze({
1591
1664
  turnId: turn.turnId,
1592
1665
  userMessage: input.userMessage,
@@ -1608,6 +1681,79 @@ class DefaultRuntimeSession implements RuntimeSession {
1608
1681
  }
1609
1682
  }
1610
1683
 
1684
+ private async continueExecutionChain(
1685
+ initialCompletion: Promise<RunAgentResult>,
1686
+ signal: AbortSignal,
1687
+ ): Promise<RunAgentResult> {
1688
+ let completion = initialCompletion;
1689
+ let finalResult: RunAgentResult;
1690
+ try {
1691
+ for (;;) {
1692
+ finalResult = await completion;
1693
+ if (finalResult.status !== "completed" || this.queuedPrompts.length === 0) {
1694
+ return finalResult;
1695
+ }
1696
+ const next = this.queuedPrompts[0];
1697
+ if (next === undefined) {
1698
+ return finalResult;
1699
+ }
1700
+ const accepted = await this.admitSingleTurn({
1701
+ userMessage: next.userMessage,
1702
+ signal,
1703
+ });
1704
+ this.queuedPrompts.shift();
1705
+ this.notifyPromptScheduler();
1706
+ completion = accepted.completion;
1707
+ }
1708
+ } finally {
1709
+ this.queuedPrompts.splice(0);
1710
+ this.executionChainRunning = false;
1711
+ this.notifyPromptScheduler();
1712
+ }
1713
+ }
1714
+
1715
+ private notifyPromptScheduler(): void {
1716
+ this.promptSchedulerSnapshot = Object.freeze({
1717
+ state: this.executionChainRunning ? "running" : "idle",
1718
+ ...(this.activeTurn === undefined
1719
+ ? {}
1720
+ : { activeTurnId: this.activeTurn.turn.turnId }),
1721
+ pendingCount: this.queuedPrompts.length,
1722
+ });
1723
+ for (const listener of this.promptSchedulerListeners) listener();
1724
+ }
1725
+
1726
+ private async applyQueuedSteering(input: {
1727
+ turn: TurnIdentity;
1728
+ ledger: AgentTurnLedger;
1729
+ }): Promise<number> {
1730
+ if (this.activeTurn?.turn.turnId !== input.turn.turnId) {
1731
+ throw new Error("Cannot apply steering outside the active turn.");
1732
+ }
1733
+ if (this.queuedPrompts.length === 0) return 0;
1734
+ const drained = this.queuedPrompts.splice(0);
1735
+ const records = input.ledger.appendSteeringUserMessages(
1736
+ drained.map((entry) => entry.userMessage),
1737
+ );
1738
+ this.notifyPromptScheduler();
1739
+ for (let index = 0; index < records.length; index += 1) {
1740
+ const record = records[index];
1741
+ const queued = drained[index];
1742
+ if (record === undefined || queued === undefined) {
1743
+ throw new Error("Steering ledger result did not match the drained queue.");
1744
+ }
1745
+ await this.append({
1746
+ type: "turn.steering.applied",
1747
+ ...input.turn,
1748
+ data: {
1749
+ userPrompt: projectUserMessage(queued.userMessage),
1750
+ ordinal: record.ordinal,
1751
+ },
1752
+ });
1753
+ }
1754
+ return records.length;
1755
+ }
1756
+
1611
1757
  private settleAdmission(admission: ActiveAdmission): void {
1612
1758
  if (this.activeAdmission !== admission) {
1613
1759
  throw new Error("Runtime admission ownership was lost.");
@@ -1838,6 +1984,8 @@ class DefaultRuntimeSession implements RuntimeSession {
1838
1984
  canSwitchSession(): boolean {
1839
1985
  return (
1840
1986
  this.state === "ready" &&
1987
+ !this.executionChainRunning &&
1988
+ this.queuedPrompts.length === 0 &&
1841
1989
  this.activeTurn === undefined &&
1842
1990
  (this.tooling?.taskManager
1843
1991
  .listBackgroundTasks()
@@ -2025,6 +2173,7 @@ class DefaultRuntimeSession implements RuntimeSession {
2025
2173
  } finally {
2026
2174
  removeExternalAbortListener();
2027
2175
  this.activeTurn = undefined;
2176
+ this.notifyPromptScheduler();
2028
2177
  this.pendingAutomaticContextMaintenance = false;
2029
2178
  if (this.state === "executing") {
2030
2179
  this.state = "ready";
@@ -2398,6 +2547,9 @@ class DefaultRuntimeSession implements RuntimeSession {
2398
2547
  }
2399
2548
 
2400
2549
  this.state = "disposing";
2550
+ this.queuedPrompts.splice(0);
2551
+ this.executionChainRunning = false;
2552
+ this.notifyPromptScheduler();
2401
2553
  const errors: unknown[] = this.faultCause === undefined ? [] : [this.faultCause];
2402
2554
  const activeAdmission = this.activeAdmission;
2403
2555
  if (activeAdmission !== undefined) {