tinker-agent 1.10.1 → 1.11.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 +11 -1
- package/package.json +1 -1
- package/src/agent/loop.ts +4 -0
- package/src/agent/runtime-session.ts +176 -1
- package/src/agent/session-ledger.ts +80 -0
- 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/model/fake-model-client.ts +29 -0
- package/src/session/session-store.ts +29 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.11.0] - 2026-08-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Allow text follow-up prompts to be queued while a turn is running. Follow-ups
|
|
13
|
+
are applied safely after a complete tool batch or handed off to a new turn
|
|
14
|
+
after a final response, while preserving canonical session history and TUI
|
|
15
|
+
continuity.
|
|
16
|
+
|
|
8
17
|
## [1.10.1] - 2026-08-15
|
|
9
18
|
|
|
10
19
|
### Added
|
|
@@ -199,7 +208,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
199
208
|
- First formal npm release under the `tinker-agent` package name with the `tinker`
|
|
200
209
|
executable.
|
|
201
210
|
|
|
202
|
-
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.
|
|
211
|
+
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.11.0...HEAD
|
|
212
|
+
[1.11.0]: https://github.com/ishowshao/tinker/releases/tag/v1.11.0
|
|
203
213
|
[1.10.1]: https://github.com/ishowshao/tinker/releases/tag/v1.10.1
|
|
204
214
|
[1.9.0]: https://github.com/ishowshao/tinker/releases/tag/v1.9.0
|
|
205
215
|
[1.8.0]: https://github.com/ishowshao/tinker/releases/tag/v1.8.0
|
package/package.json
CHANGED
package/src/agent/loop.ts
CHANGED
|
@@ -498,6 +498,10 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
await input.runtimeSession.applyQueuedSteering?.({
|
|
502
|
+
turn: input.turn,
|
|
503
|
+
ledger: input.ledger,
|
|
504
|
+
});
|
|
501
505
|
await input.runtimeSession.append({
|
|
502
506
|
type: "agent.iteration.finished",
|
|
503
507
|
...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
|
|
|
@@ -1509,11 +1544,71 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1509
1544
|
}
|
|
1510
1545
|
}
|
|
1511
1546
|
|
|
1547
|
+
promptScheduler(): PromptSchedulerSnapshot {
|
|
1548
|
+
return this.promptSchedulerSnapshot;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
subscribePromptScheduler(listener: () => void): () => void {
|
|
1552
|
+
this.promptSchedulerListeners.add(listener);
|
|
1553
|
+
return () => this.promptSchedulerListeners.delete(listener);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
queueFollowUp(userMessage: UserMessage): QueueFollowUpResult {
|
|
1557
|
+
if (!this.executionChainRunning) {
|
|
1558
|
+
throw new Error("Cannot queue a follow-up while no execution chain is running.");
|
|
1559
|
+
}
|
|
1560
|
+
validateUserMessage(userMessage);
|
|
1561
|
+
if (userMessage.attachments !== undefined) {
|
|
1562
|
+
throw new Error("Active-turn follow-ups do not support image attachments.");
|
|
1563
|
+
}
|
|
1564
|
+
if (this.queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
|
|
1565
|
+
throw new Error(`At most ${MAX_QUEUED_PROMPTS} follow-ups may be queued.`);
|
|
1566
|
+
}
|
|
1567
|
+
const queuedBytes = this.queuedPrompts.reduce(
|
|
1568
|
+
(total, entry) => total + Buffer.byteLength(entry.userMessage.content, "utf8"),
|
|
1569
|
+
0,
|
|
1570
|
+
);
|
|
1571
|
+
const nextBytes = Buffer.byteLength(userMessage.content, "utf8");
|
|
1572
|
+
if (queuedBytes + nextBytes > MAX_QUEUED_PROMPT_TEXT_BYTES) {
|
|
1573
|
+
throw new Error("Queued follow-ups exceed the 64 KiB text limit.");
|
|
1574
|
+
}
|
|
1575
|
+
this.queuedPrompts.push({
|
|
1576
|
+
userMessage: Object.freeze({ ...userMessage }),
|
|
1577
|
+
});
|
|
1578
|
+
this.notifyPromptScheduler();
|
|
1579
|
+
return Object.freeze({
|
|
1580
|
+
kind: "queued",
|
|
1581
|
+
pendingCount: this.queuedPrompts.length,
|
|
1582
|
+
...(this.activeTurn === undefined
|
|
1583
|
+
? {}
|
|
1584
|
+
: { activeTurnId: this.activeTurn.turn.turnId }),
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1512
1588
|
async executeTurn(input: ExecuteTurnInput): Promise<RunAgentResult> {
|
|
1513
1589
|
return (await this.admitTurn(input)).completion;
|
|
1514
1590
|
}
|
|
1515
1591
|
|
|
1516
1592
|
async admitTurn(input: ExecuteTurnInput): Promise<AcceptedTurn> {
|
|
1593
|
+
if (this.executionChainRunning) {
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
`Cannot execute a turn while RuntimeSession is ${this.state}; a prompt chain is already executing.`,
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
this.executionChainRunning = true;
|
|
1599
|
+
this.notifyPromptScheduler();
|
|
1600
|
+
try {
|
|
1601
|
+
const accepted = await this.admitSingleTurn(input);
|
|
1602
|
+
const completion = this.continueExecutionChain(accepted.completion, input.signal);
|
|
1603
|
+
return Object.freeze({ ...accepted, completion });
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
this.executionChainRunning = false;
|
|
1606
|
+
this.notifyPromptScheduler();
|
|
1607
|
+
throw error;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
private async admitSingleTurn(input: ExecuteTurnInput): Promise<AcceptedTurn> {
|
|
1517
1612
|
if (this.state !== "ready") {
|
|
1518
1613
|
throw new Error(`Cannot execute a turn while RuntimeSession is ${this.state}.`);
|
|
1519
1614
|
}
|
|
@@ -1586,7 +1681,8 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1586
1681
|
usage: admissionSnapshot,
|
|
1587
1682
|
},
|
|
1588
1683
|
});
|
|
1589
|
-
this.activeTurn = { controller, completion };
|
|
1684
|
+
this.activeTurn = { turn, controller, completion };
|
|
1685
|
+
this.notifyPromptScheduler();
|
|
1590
1686
|
return Object.freeze({
|
|
1591
1687
|
turnId: turn.turnId,
|
|
1592
1688
|
userMessage: input.userMessage,
|
|
@@ -1608,6 +1704,79 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1608
1704
|
}
|
|
1609
1705
|
}
|
|
1610
1706
|
|
|
1707
|
+
private async continueExecutionChain(
|
|
1708
|
+
initialCompletion: Promise<RunAgentResult>,
|
|
1709
|
+
signal: AbortSignal,
|
|
1710
|
+
): Promise<RunAgentResult> {
|
|
1711
|
+
let completion = initialCompletion;
|
|
1712
|
+
let finalResult: RunAgentResult;
|
|
1713
|
+
try {
|
|
1714
|
+
for (;;) {
|
|
1715
|
+
finalResult = await completion;
|
|
1716
|
+
if (finalResult.status !== "completed" || this.queuedPrompts.length === 0) {
|
|
1717
|
+
return finalResult;
|
|
1718
|
+
}
|
|
1719
|
+
const next = this.queuedPrompts[0];
|
|
1720
|
+
if (next === undefined) {
|
|
1721
|
+
return finalResult;
|
|
1722
|
+
}
|
|
1723
|
+
const accepted = await this.admitSingleTurn({
|
|
1724
|
+
userMessage: next.userMessage,
|
|
1725
|
+
signal,
|
|
1726
|
+
});
|
|
1727
|
+
this.queuedPrompts.shift();
|
|
1728
|
+
this.notifyPromptScheduler();
|
|
1729
|
+
completion = accepted.completion;
|
|
1730
|
+
}
|
|
1731
|
+
} finally {
|
|
1732
|
+
this.queuedPrompts.splice(0);
|
|
1733
|
+
this.executionChainRunning = false;
|
|
1734
|
+
this.notifyPromptScheduler();
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
private notifyPromptScheduler(): void {
|
|
1739
|
+
this.promptSchedulerSnapshot = Object.freeze({
|
|
1740
|
+
state: this.executionChainRunning ? "running" : "idle",
|
|
1741
|
+
...(this.activeTurn === undefined
|
|
1742
|
+
? {}
|
|
1743
|
+
: { activeTurnId: this.activeTurn.turn.turnId }),
|
|
1744
|
+
pendingCount: this.queuedPrompts.length,
|
|
1745
|
+
});
|
|
1746
|
+
for (const listener of this.promptSchedulerListeners) listener();
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
private async applyQueuedSteering(input: {
|
|
1750
|
+
turn: TurnIdentity;
|
|
1751
|
+
ledger: AgentTurnLedger;
|
|
1752
|
+
}): Promise<number> {
|
|
1753
|
+
if (this.activeTurn?.turn.turnId !== input.turn.turnId) {
|
|
1754
|
+
throw new Error("Cannot apply steering outside the active turn.");
|
|
1755
|
+
}
|
|
1756
|
+
if (this.queuedPrompts.length === 0) return 0;
|
|
1757
|
+
const drained = this.queuedPrompts.splice(0);
|
|
1758
|
+
const records = input.ledger.appendSteeringUserMessages(
|
|
1759
|
+
drained.map((entry) => entry.userMessage),
|
|
1760
|
+
);
|
|
1761
|
+
this.notifyPromptScheduler();
|
|
1762
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
1763
|
+
const record = records[index];
|
|
1764
|
+
const queued = drained[index];
|
|
1765
|
+
if (record === undefined || queued === undefined) {
|
|
1766
|
+
throw new Error("Steering ledger result did not match the drained queue.");
|
|
1767
|
+
}
|
|
1768
|
+
await this.append({
|
|
1769
|
+
type: "turn.steering.applied",
|
|
1770
|
+
...input.turn,
|
|
1771
|
+
data: {
|
|
1772
|
+
userPrompt: projectUserMessage(queued.userMessage),
|
|
1773
|
+
ordinal: record.ordinal,
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
return records.length;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1611
1780
|
private settleAdmission(admission: ActiveAdmission): void {
|
|
1612
1781
|
if (this.activeAdmission !== admission) {
|
|
1613
1782
|
throw new Error("Runtime admission ownership was lost.");
|
|
@@ -1838,6 +2007,8 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1838
2007
|
canSwitchSession(): boolean {
|
|
1839
2008
|
return (
|
|
1840
2009
|
this.state === "ready" &&
|
|
2010
|
+
!this.executionChainRunning &&
|
|
2011
|
+
this.queuedPrompts.length === 0 &&
|
|
1841
2012
|
this.activeTurn === undefined &&
|
|
1842
2013
|
(this.tooling?.taskManager
|
|
1843
2014
|
.listBackgroundTasks()
|
|
@@ -2025,6 +2196,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
2025
2196
|
} finally {
|
|
2026
2197
|
removeExternalAbortListener();
|
|
2027
2198
|
this.activeTurn = undefined;
|
|
2199
|
+
this.notifyPromptScheduler();
|
|
2028
2200
|
this.pendingAutomaticContextMaintenance = false;
|
|
2029
2201
|
if (this.state === "executing") {
|
|
2030
2202
|
this.state = "ready";
|
|
@@ -2398,6 +2570,9 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
2398
2570
|
}
|
|
2399
2571
|
|
|
2400
2572
|
this.state = "disposing";
|
|
2573
|
+
this.queuedPrompts.splice(0);
|
|
2574
|
+
this.executionChainRunning = false;
|
|
2575
|
+
this.notifyPromptScheduler();
|
|
2401
2576
|
const errors: unknown[] = this.faultCause === undefined ? [] : [this.faultCause];
|
|
2402
2577
|
const activeAdmission = this.activeAdmission;
|
|
2403
2578
|
if (activeAdmission !== undefined) {
|
|
@@ -100,6 +100,9 @@ export type PendingLedgerTurn = {
|
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
export type AgentTurnLedger = {
|
|
103
|
+
appendSteeringUserMessages(
|
|
104
|
+
messages: readonly UserMessage[],
|
|
105
|
+
): readonly CanonicalMessageRecord[];
|
|
103
106
|
appendAssistant(input: {
|
|
104
107
|
iteration: IterationIdentity;
|
|
105
108
|
message: AssistantMessage;
|
|
@@ -129,6 +132,13 @@ export type LedgerMutation =
|
|
|
129
132
|
admissionBase?: AdmissionBaseToken;
|
|
130
133
|
next: ProtocolContextView;
|
|
131
134
|
}
|
|
135
|
+
| {
|
|
136
|
+
kind: "append_steering_users";
|
|
137
|
+
turn: TurnIdentity;
|
|
138
|
+
frames: readonly ProtocolFrame[];
|
|
139
|
+
messages: readonly CanonicalMessageRecord[];
|
|
140
|
+
next: ProtocolContextView;
|
|
141
|
+
}
|
|
132
142
|
| {
|
|
133
143
|
kind: "append_assistant";
|
|
134
144
|
iteration: IterationIdentity;
|
|
@@ -396,6 +406,74 @@ export class InMemorySessionLedger implements SessionLedger {
|
|
|
396
406
|
this.pending = undefined;
|
|
397
407
|
}
|
|
398
408
|
|
|
409
|
+
appendSteeringUserMessages(
|
|
410
|
+
pending: InMemoryPendingLedgerTurn,
|
|
411
|
+
userMessages: readonly UserMessage[],
|
|
412
|
+
): readonly CanonicalMessageRecord[] {
|
|
413
|
+
this.requirePending(pending, "append steering user messages");
|
|
414
|
+
this.assertNoOpenFrame();
|
|
415
|
+
if (userMessages.length === 0) {
|
|
416
|
+
return Object.freeze([]);
|
|
417
|
+
}
|
|
418
|
+
for (const userMessage of userMessages) {
|
|
419
|
+
validateUserMessage(userMessage);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const frames: ProtocolFrame[] = [];
|
|
423
|
+
const messages: CanonicalMessageRecord[] = [];
|
|
424
|
+
for (const userMessage of userMessages) {
|
|
425
|
+
const createdAt = this.clock();
|
|
426
|
+
const ordinal = this.view.messages.length + messages.length + 1;
|
|
427
|
+
const frameId = this.input.idFactory.createProtocolFrameId();
|
|
428
|
+
messages.push(
|
|
429
|
+
immutableRecord<CanonicalMessageRecord>({
|
|
430
|
+
messageId: this.input.idFactory.createMessageId(),
|
|
431
|
+
sessionId: this.input.sessionId,
|
|
432
|
+
frameId,
|
|
433
|
+
ordinal,
|
|
434
|
+
contentSha256: userMessageHash(userMessage),
|
|
435
|
+
createdAt,
|
|
436
|
+
role: "user",
|
|
437
|
+
turnId: pending.turn.turnId,
|
|
438
|
+
content: userMessage.content,
|
|
439
|
+
...(userMessage.attachments === undefined
|
|
440
|
+
? {}
|
|
441
|
+
: {
|
|
442
|
+
attachments: Object.freeze(
|
|
443
|
+
immutableCanonicalClone(userMessage.attachments),
|
|
444
|
+
),
|
|
445
|
+
}),
|
|
446
|
+
origin: "user",
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
frames.push(
|
|
450
|
+
immutableRecord<ProtocolFrame>({
|
|
451
|
+
frameId,
|
|
452
|
+
sessionId: this.input.sessionId,
|
|
453
|
+
turnId: pending.turn.turnId,
|
|
454
|
+
kind: "user",
|
|
455
|
+
state: "closed",
|
|
456
|
+
firstOrdinal: ordinal,
|
|
457
|
+
lastOrdinal: ordinal,
|
|
458
|
+
createdAt,
|
|
459
|
+
closedAt: createdAt,
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const immutableFrames = Object.freeze(frames);
|
|
464
|
+
const immutableMessages = Object.freeze(messages);
|
|
465
|
+
const next = appendView(this.view, immutableFrames, immutableMessages, []);
|
|
466
|
+
this.validator.validate(next, { fullIntegrity: true });
|
|
467
|
+
this.commit({
|
|
468
|
+
kind: "append_steering_users",
|
|
469
|
+
turn: pending.turn,
|
|
470
|
+
frames: immutableFrames,
|
|
471
|
+
messages: immutableMessages,
|
|
472
|
+
next,
|
|
473
|
+
});
|
|
474
|
+
return immutableMessages;
|
|
475
|
+
}
|
|
476
|
+
|
|
399
477
|
appendAssistant(
|
|
400
478
|
pending: InMemoryPendingLedgerTurn,
|
|
401
479
|
input: {
|
|
@@ -776,6 +854,8 @@ class InMemoryPendingLedgerTurn implements PendingLedgerTurn {
|
|
|
776
854
|
readonly turn: TurnIdentity,
|
|
777
855
|
) {
|
|
778
856
|
this.agent = {
|
|
857
|
+
appendSteeringUserMessages: (messages) =>
|
|
858
|
+
this.ledger.appendSteeringUserMessages(this, messages),
|
|
779
859
|
appendAssistant: (input) => this.ledger.appendAssistant(this, input),
|
|
780
860
|
assertCanExecuteTool: (call) => this.ledger.assertCanExecuteTool(this, call),
|
|
781
861
|
commitToolCompletions: (completions) =>
|
|
@@ -35,6 +35,10 @@ export function renderObservationLogEvent(event: AgentEvent): string | undefined
|
|
|
35
35
|
].join("\n");
|
|
36
36
|
case "turn.started":
|
|
37
37
|
return renderTurnStarted(event);
|
|
38
|
+
case "turn.steering.applied":
|
|
39
|
+
return ["## User follow-up", "", event.data.userPrompt.text, "", "---", ""].join(
|
|
40
|
+
"\n",
|
|
41
|
+
);
|
|
38
42
|
case "assistant.progress":
|
|
39
43
|
return renderAssistantProgress(event);
|
|
40
44
|
case "tool.observation":
|
|
@@ -37,6 +37,11 @@ export class StdoutEventPrinter implements EventSink {
|
|
|
37
37
|
`turn.started turn=${event.turnNumber} turnId=${event.turnId}\n`,
|
|
38
38
|
);
|
|
39
39
|
break;
|
|
40
|
+
case "turn.steering.applied":
|
|
41
|
+
this.stdout.write(
|
|
42
|
+
`turn.steering.applied turn=${event.turnNumber} ordinal=${event.data.ordinal}\n`,
|
|
43
|
+
);
|
|
44
|
+
break;
|
|
40
45
|
case "agent.iteration.started":
|
|
41
46
|
this.stdout.write(
|
|
42
47
|
`agent.iteration.started iteration=${event.iterationNumber} iterationId=${event.iterationId}\n`,
|
package/src/events/types.ts
CHANGED
|
@@ -307,6 +307,10 @@ export type AgentEventDataMap = {
|
|
|
307
307
|
"session.interrupted_frame_recovered": InterruptedFrameRecoveredData;
|
|
308
308
|
"session.finished": SessionFinishedData;
|
|
309
309
|
"turn.started": { userPrompt: UserPromptProjection };
|
|
310
|
+
"turn.steering.applied": {
|
|
311
|
+
userPrompt: UserPromptProjection;
|
|
312
|
+
ordinal: number;
|
|
313
|
+
};
|
|
310
314
|
"turn.finished": TurnFinishedData;
|
|
311
315
|
"turn.failed": { error: string };
|
|
312
316
|
"turn.cancelled": { cancellation: TurnCancellation };
|
|
@@ -411,7 +415,7 @@ export type AgentEventInput =
|
|
|
411
415
|
>
|
|
412
416
|
| SessionEventInput<"mcp.server.connected" | "mcp.server.failed">
|
|
413
417
|
| SessionEventInput<"diagnostic.sink_failed">
|
|
414
|
-
| TurnEventInput<"turn.started" | "turn.finished">
|
|
418
|
+
| TurnEventInput<"turn.started" | "turn.steering.applied" | "turn.finished">
|
|
415
419
|
| (
|
|
416
420
|
| TurnEventInput<"turn.failed" | "turn.cancelled">
|
|
417
421
|
| IterationEventInput<"turn.failed" | "turn.cancelled">
|
|
@@ -241,6 +241,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
241
241
|
if (this.mode === "pty-incremental-output") {
|
|
242
242
|
return this.ptyIncrementalOutput(input, prepared, options);
|
|
243
243
|
}
|
|
244
|
+
if (this.mode === "pty-steering-notice") {
|
|
245
|
+
return this.ptySteeringNotice(input, prepared, options);
|
|
246
|
+
}
|
|
244
247
|
if (this.mode === "pty-resume-layout") {
|
|
245
248
|
return this.ptyResumeLayout(input, prepared, options);
|
|
246
249
|
}
|
|
@@ -412,6 +415,32 @@ export class FakeModelClient implements ModelClient {
|
|
|
412
415
|
return textOutput(prepared, chunks.join(""));
|
|
413
416
|
}
|
|
414
417
|
|
|
418
|
+
private async ptySteeringNotice(
|
|
419
|
+
input: ModelRequestInput,
|
|
420
|
+
prepared: PreparedModelRequest,
|
|
421
|
+
options: ModelRequestOptions,
|
|
422
|
+
): Promise<ModelRequestOutput> {
|
|
423
|
+
requireTools(input, ["Bash"]);
|
|
424
|
+
const prompt = lastUserMessage(input.messages);
|
|
425
|
+
if (prompt === "PTY_STEERING_START") {
|
|
426
|
+
await Bun.sleep(600);
|
|
427
|
+
options.signal.throwIfAborted();
|
|
428
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
429
|
+
command: "printf 'PTY_STEERING_TOOL_DONE\\n'",
|
|
430
|
+
description: "Create steering boundary",
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (prompt === "PTY_STEERING_FOLLOWUP") {
|
|
434
|
+
requireToolMessage(input.messages, "Bash", "PTY_STEERING_TOOL_DONE");
|
|
435
|
+
await Bun.sleep(1_500);
|
|
436
|
+
options.signal.throwIfAborted();
|
|
437
|
+
return textOutput(prepared, "PTY_STEERING_FINAL");
|
|
438
|
+
}
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Unexpected pty-steering-notice prompt: ${JSON.stringify(prompt)}.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
415
444
|
private ptyResumeLayout(
|
|
416
445
|
input: ModelRequestInput,
|
|
417
446
|
prepared: PreparedModelRequest,
|
|
@@ -660,6 +660,9 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
660
660
|
case "begin_turn":
|
|
661
661
|
this.commitBeginTurn(mutation, now);
|
|
662
662
|
break;
|
|
663
|
+
case "append_steering_users":
|
|
664
|
+
this.commitSteeringUsers(mutation, now);
|
|
665
|
+
break;
|
|
663
666
|
case "append_assistant":
|
|
664
667
|
this.commitAssistant(mutation, now);
|
|
665
668
|
break;
|
|
@@ -2848,6 +2851,32 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
2848
2851
|
requireSingleChange(this.database, updated.changes, "advance turn counter");
|
|
2849
2852
|
}
|
|
2850
2853
|
|
|
2854
|
+
private commitSteeringUsers(
|
|
2855
|
+
mutation: Extract<LedgerMutation, { kind: "append_steering_users" }>,
|
|
2856
|
+
now: string,
|
|
2857
|
+
): void {
|
|
2858
|
+
const turn = this.requireTurnRow(mutation.turn.turnId);
|
|
2859
|
+
if (turn.status !== "open") {
|
|
2860
|
+
throw new Error(`Turn ${mutation.turn.turnId} is not open.`);
|
|
2861
|
+
}
|
|
2862
|
+
if (
|
|
2863
|
+
mutation.frames.length === 0 ||
|
|
2864
|
+
mutation.frames.length !== mutation.messages.length
|
|
2865
|
+
) {
|
|
2866
|
+
throw new Error(
|
|
2867
|
+
"Steering user mutation must contain matching frames and messages.",
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
for (let index = 0; index < mutation.frames.length; index += 1) {
|
|
2871
|
+
insertFrame(this.database, requireItem(mutation.frames, index, "steering frame"));
|
|
2872
|
+
insertMessage(
|
|
2873
|
+
this.database,
|
|
2874
|
+
requireItem(mutation.messages, index, "steering message"),
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
this.touch(now);
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2851
2880
|
private commitAssistant(
|
|
2852
2881
|
mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
|
|
2853
2882
|
now: string,
|
package/src/tui/app.tsx
CHANGED
|
@@ -108,6 +108,10 @@ const STATIC_HEADER = Symbol("tui-static-header");
|
|
|
108
108
|
const LIVE_TIMELINE_MAX_ROWS = 8;
|
|
109
109
|
const LIVE_TIMELINE_WITH_TASKS_MAX_ROWS = 3;
|
|
110
110
|
const BACKGROUND_TASKS_MAX_ROWS = 12;
|
|
111
|
+
const IDLE_PROMPT_SCHEDULER = Object.freeze({
|
|
112
|
+
state: "idle" as const,
|
|
113
|
+
pendingCount: 0,
|
|
114
|
+
});
|
|
111
115
|
|
|
112
116
|
export function App(props: AppProps) {
|
|
113
117
|
const { exit } = useApp();
|
|
@@ -137,11 +141,23 @@ export function App(props: AppProps) {
|
|
|
137
141
|
() => binding.bashGuard(),
|
|
138
142
|
() => binding.bashGuard(),
|
|
139
143
|
);
|
|
144
|
+
const promptScheduler = useSyncExternalStore(
|
|
145
|
+
(listener) => binding.subscribePromptScheduler?.(listener) ?? (() => undefined),
|
|
146
|
+
() => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
|
|
147
|
+
() => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
|
|
148
|
+
);
|
|
140
149
|
const [isRunning, setIsRunning] = useState(false);
|
|
150
|
+
const executionRunning = isRunning || promptScheduler.state === "running";
|
|
141
151
|
const [isSessionOperation, setIsSessionOperation] = useState(false);
|
|
142
152
|
const [isCopying, setIsCopying] = useState(false);
|
|
143
153
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
144
154
|
const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
|
|
155
|
+
const currentQueuedFollowUpNotice = `Follow-up queued for the active turn (${promptScheduler.pendingCount} pending).`;
|
|
156
|
+
const visibleNotice =
|
|
157
|
+
notice?.startsWith("Follow-up queued for the active turn (") === true &&
|
|
158
|
+
notice !== currentQueuedFollowUpNotice
|
|
159
|
+
? undefined
|
|
160
|
+
: notice;
|
|
145
161
|
const [showStatus, setShowStatus] = useState(false);
|
|
146
162
|
const [showSkills, setShowSkills] = useState(false);
|
|
147
163
|
const [showMcp, setShowMcp] = useState(false);
|
|
@@ -259,7 +275,7 @@ export function App(props: AppProps) {
|
|
|
259
275
|
setIsCancelling(true);
|
|
260
276
|
setNotice("Cancelling current turn...");
|
|
261
277
|
},
|
|
262
|
-
{ isActive:
|
|
278
|
+
{ isActive: executionRunning },
|
|
263
279
|
);
|
|
264
280
|
|
|
265
281
|
const closeResumePicker = () => {
|
|
@@ -449,6 +465,32 @@ export function App(props: AppProps) {
|
|
|
449
465
|
submission: PromptSubmission,
|
|
450
466
|
admissionSignal: AbortSignal,
|
|
451
467
|
): Promise<PromptSubmissionOutcome> => {
|
|
468
|
+
if (promptScheduler.state === "running") {
|
|
469
|
+
if (submission.userMessage.attachments !== undefined) {
|
|
470
|
+
setNotice("Active-turn follow-ups do not support image attachments.");
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (submission.userMessage.content.trimStart().startsWith("/")) {
|
|
474
|
+
setNotice("Slash commands cannot be queued while a turn is running.");
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const queued = binding.queueFollowUp?.(submission.userMessage);
|
|
479
|
+
if (queued === undefined) {
|
|
480
|
+
throw new Error("TUI session binding does not support follow-up queuing.");
|
|
481
|
+
}
|
|
482
|
+
void props.history?.append(submission.draft).catch((error: unknown) => {
|
|
483
|
+
setNotice(`Prompt history write failed: ${errorMessage(error)}`);
|
|
484
|
+
});
|
|
485
|
+
setNotice(
|
|
486
|
+
`Follow-up queued for the active turn (${queued.pendingCount} pending).`,
|
|
487
|
+
);
|
|
488
|
+
return true;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
setNotice(`Follow-up was not queued: ${errorMessage(error)}`);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
452
494
|
if (isRunning) return false;
|
|
453
495
|
setNotice(undefined);
|
|
454
496
|
setIsCancelling(false);
|
|
@@ -557,6 +599,10 @@ export function App(props: AppProps) {
|
|
|
557
599
|
restoreStaticViewport();
|
|
558
600
|
}
|
|
559
601
|
|
|
602
|
+
if (promptScheduler.state === "running") {
|
|
603
|
+
return submitAgentPrompt(submission, signal);
|
|
604
|
+
}
|
|
605
|
+
|
|
560
606
|
if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
|
|
561
607
|
try {
|
|
562
608
|
const projectCommand = resolveProjectSlashCommand(
|
|
@@ -856,9 +902,16 @@ export function App(props: AppProps) {
|
|
|
856
902
|
</Box>
|
|
857
903
|
<Box marginTop={1} flexShrink={0}>
|
|
858
904
|
<Footer
|
|
859
|
-
status={
|
|
905
|
+
status={
|
|
906
|
+
isCancelling
|
|
907
|
+
? "cancelling"
|
|
908
|
+
: executionRunning
|
|
909
|
+
? "running"
|
|
910
|
+
: state.status
|
|
911
|
+
}
|
|
860
912
|
workedForMs={state.workedForMs}
|
|
861
913
|
yolo={bashGuard.mode === "yolo"}
|
|
914
|
+
pendingFollowUps={promptScheduler.pendingCount}
|
|
862
915
|
/>
|
|
863
916
|
</Box>
|
|
864
917
|
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
|
@@ -891,26 +944,38 @@ export function App(props: AppProps) {
|
|
|
891
944
|
gitBranch={gitBranch}
|
|
892
945
|
contextUsage={state.contextUsage}
|
|
893
946
|
isDisabled={
|
|
894
|
-
isRunning ||
|
|
895
947
|
isSessionOperation ||
|
|
896
948
|
isCopying ||
|
|
949
|
+
isCancelling ||
|
|
897
950
|
bashGuard.pending !== undefined
|
|
898
951
|
}
|
|
899
952
|
history={props.history}
|
|
900
953
|
commands={availableCommands}
|
|
901
954
|
fileLister={props.fileLister}
|
|
902
|
-
importImage={
|
|
955
|
+
importImage={
|
|
956
|
+
promptScheduler.state === "running"
|
|
957
|
+
? undefined
|
|
958
|
+
: binding.importImage
|
|
959
|
+
}
|
|
903
960
|
verifyImageAssets={binding.verifyImageAssets}
|
|
904
961
|
onCycleReasoningEffort={
|
|
905
|
-
hasReasoningEffort
|
|
962
|
+
hasReasoningEffort && promptScheduler.state !== "running"
|
|
963
|
+
? cycleReasoningEffort
|
|
964
|
+
: undefined
|
|
906
965
|
}
|
|
907
966
|
onSubmit={onSubmit}
|
|
908
967
|
onMaintenance={onMaintenance}
|
|
909
|
-
placeholder=
|
|
968
|
+
placeholder={
|
|
969
|
+
promptScheduler.state === "running"
|
|
970
|
+
? "Send a follow-up for the active turn…"
|
|
971
|
+
: 'Enter a coding request, or "/" for commands'
|
|
972
|
+
}
|
|
910
973
|
/>
|
|
911
974
|
)}
|
|
912
975
|
{viewError === undefined ? null : <Text color="red">{viewError}</Text>}
|
|
913
|
-
{
|
|
976
|
+
{visibleNotice === undefined ? null : (
|
|
977
|
+
<Text color="yellow">{visibleNotice}</Text>
|
|
978
|
+
)}
|
|
914
979
|
</Box>
|
|
915
980
|
</Box>
|
|
916
981
|
)}
|
|
@@ -4,6 +4,7 @@ export type FooterProps = {
|
|
|
4
4
|
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
5
5
|
workedForMs?: number;
|
|
6
6
|
yolo?: boolean;
|
|
7
|
+
pendingFollowUps?: number;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
export function Footer(props: FooterProps) {
|
|
@@ -26,7 +27,11 @@ export function Footer(props: FooterProps) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
if (props.status === "running") {
|
|
29
|
-
|
|
30
|
+
const queued =
|
|
31
|
+
props.pendingFollowUps === undefined || props.pendingFollowUps === 0
|
|
32
|
+
? ""
|
|
33
|
+
: ` · ${props.pendingFollowUps} follow-up${props.pendingFollowUps === 1 ? "" : "s"} queued`;
|
|
34
|
+
return <Spinner label={`Running${queued}${suffix}`} />;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
if (props.status === "cancelling") {
|
package/src/tui/event-store.ts
CHANGED
|
@@ -155,6 +155,21 @@ export function reduceTuiProjection(
|
|
|
155
155
|
},
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
|
+
case "turn.steering.applied": {
|
|
159
|
+
const userPrompt = truncateUserPromptProjection(
|
|
160
|
+
event.data.userPrompt,
|
|
161
|
+
MAX_TIMELINE_PROMPT_CODE_POINTS,
|
|
162
|
+
);
|
|
163
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
164
|
+
appendTurnItem(turn, {
|
|
165
|
+
id: `turn-${event.turnId}-steering-${event.eventSequence}`,
|
|
166
|
+
label: "follow-up",
|
|
167
|
+
text: userPrompt.text,
|
|
168
|
+
userPrompt,
|
|
169
|
+
status: "text",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
158
173
|
case "model.request.started":
|
|
159
174
|
return updateActiveTurn(state, event, policy, (turn) =>
|
|
160
175
|
event.data.attemptNumber === 1
|
|
@@ -45,6 +45,9 @@ export type TuiSessionBinding = {
|
|
|
45
45
|
) => Promise<void>;
|
|
46
46
|
admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
|
|
47
47
|
executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
|
|
48
|
+
promptScheduler?: RuntimeSession["promptScheduler"];
|
|
49
|
+
subscribePromptScheduler?: RuntimeSession["subscribePromptScheduler"];
|
|
50
|
+
queueFollowUp?: RuntimeSession["queueFollowUp"];
|
|
48
51
|
bashGuard(): BashGuardSnapshot;
|
|
49
52
|
subscribeBashGuard(listener: () => void): () => void;
|
|
50
53
|
setYoloMode(enabled: boolean): void;
|
|
@@ -250,6 +253,10 @@ export function managedTuiBinding(input: {
|
|
|
250
253
|
userMessage,
|
|
251
254
|
signal,
|
|
252
255
|
} satisfies ExecuteTurnInput),
|
|
256
|
+
promptScheduler: () => input.runtimeSession.promptScheduler(),
|
|
257
|
+
subscribePromptScheduler: (listener) =>
|
|
258
|
+
input.runtimeSession.subscribePromptScheduler(listener),
|
|
259
|
+
queueFollowUp: (userMessage) => input.runtimeSession.queueFollowUp(userMessage),
|
|
253
260
|
bashGuard: () => input.runtimeSession.bashGuard(),
|
|
254
261
|
subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
|
|
255
262
|
setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
|