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 +27 -1
- package/README.md +7 -23
- package/package.json +1 -1
- package/src/agent/context-meter.ts +14 -85
- package/src/agent/loop.ts +5 -14
- package/src/agent/runtime-session.ts +176 -24
- package/src/agent/session-ledger.ts +80 -0
- package/src/cli/config.ts +0 -5
- package/src/cli/model-profiles.ts +0 -98
- package/src/cli/public-config-contract.ts +1 -88
- package/src/cli/runner-dependencies.ts +0 -7
- package/src/cli/tui-memory.ts +0 -3
- package/src/events/observation-text-log.ts +4 -0
- package/src/events/stdout-event-printer.ts +5 -0
- package/src/events/types.ts +5 -1
- package/src/image/image-input-policy.ts +53 -2
- package/src/image/image-probe.ts +8 -2
- package/src/image/provider-image.ts +99 -0
- package/src/model/fake-model-client.ts +98 -69
- package/src/model/model-client.ts +2 -2
- package/src/model/model-request-preflight.ts +0 -1
- package/src/model/openai-chat-model-client.ts +4 -28
- package/src/model/openai-model-utils.ts +69 -31
- package/src/model/openai-responses-model-client.ts +0 -29
- package/src/model/token-estimator.ts +16 -3
- package/src/session/session-store.ts +42 -23
- package/src/tui/app.tsx +72 -7
- package/src/tui/components/footer.tsx +6 -1
- package/src/tui/event-store.ts +15 -0
- package/src/tui/tui-session-controller.ts +7 -0
- package/src/model/input-token-estimator.ts +0 -25
- package/src/model/moonshot-input-token-estimator.ts +0 -111
- package/src/model/openai-responses-token-estimator.ts +0 -155
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/
|
|
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
|
|
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.
|
|
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
|
@@ -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 =
|
|
126
|
+
usedInputTokens = guardedEstimate(rawFullEstimate, correctionFactor);
|
|
133
127
|
} else {
|
|
134
128
|
source = "measured_plus_estimated_delta";
|
|
135
|
-
|
|
129
|
+
const rawDelta = estimatePromptSegments(
|
|
136
130
|
prepared.promptSegments.slice(anchor.segmentCount),
|
|
137
|
-
)
|
|
138
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
):
|
|
366
|
-
return
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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) {
|