@modelprofile.com/flexharness 15.0.0 → 16.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.cachewarm.d.ts +2 -2
- package/dist_ts/classes.flexharness.cachewarm.js +1 -1
- package/dist_ts/classes.flexharness.core.js +14 -20
- package/dist_ts/classes.flexharness.d.ts +2 -1
- package/dist_ts/classes.flexharness.js +4 -1
- package/dist_ts/classes.flexharness.prompts.d.ts +9 -5
- package/dist_ts/classes.flexharness.prompts.js +83 -16
- package/dist_ts/classes.flexharness.runs.d.ts +4 -4
- package/dist_ts/classes.flexharness.runs.js +31 -17
- package/dist_ts/classes.flexharness.subagents.js +1 -1
- package/dist_ts/interfaces.d.ts +23 -9
- package/dist_ts/interfaces.flexharness.d.ts +10 -8
- package/dist_ts/interfaces.js +1 -1
- package/dist_ts/plugins.d.ts +1 -1
- package/dist_ts/plugins.js +1 -1
- package/dist_ts/utils.json.js +32 -18
- package/package.json +3 -3
- package/readme.md +51 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.cachewarm.ts +2 -2
- package/ts/classes.flexharness.core.ts +13 -21
- package/ts/classes.flexharness.prompts.ts +113 -38
- package/ts/classes.flexharness.runs.ts +35 -25
- package/ts/classes.flexharness.subagents.ts +2 -1
- package/ts/classes.flexharness.ts +10 -0
- package/ts/interfaces.flexharness.ts +10 -8
- package/ts/interfaces.ts +26 -9
- package/ts/plugins.ts +1 -0
- package/ts/readme.md +51 -2
- package/ts/utils.json.ts +34 -18
package/readme.md
CHANGED
|
@@ -498,7 +498,7 @@ Sending requires the exact active parent run, its currently delegated direct chi
|
|
|
498
498
|
|
|
499
499
|
Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and separately bounded harness-owned agent/task metadata. Its metadata includes `childSessionId`, the exact ID reserved for this call: FlexHarness derives it deterministically when `taskId` is omitted and copies the supplied candidate when `taskId` is present. A resume also retains that candidate as `taskId`. This block is not truncated by `toolOutputLimits`. The reserved ID binds permission handling before child creation but does not prove that the child exists or is owned: applications must treat the later delegated admission context as authoritative. The controller answers through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
|
|
500
500
|
|
|
501
|
-
Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` accepts `sessionId`, `sessionGenerationId`, `configurationRef`, and `title`. A child inherits its parent's `configurationRef` and cannot resume under a different reference. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
|
|
501
|
+
Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` accepts `sessionId`, `sessionGenerationId`, `configurationRef`, and `title`. A child inherits its parent's `configurationRef` and cannot resume under a different reference. Child sessions reject direct `continuePrompt()`, `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
|
|
502
502
|
|
|
503
503
|
The parent tool part receives `childSessionId` as soon as the child is acquired, so a host can offer an authorized live transcript. A foreground call also adds the resolved child model to its part and returns bounded JSON:
|
|
504
504
|
|
|
@@ -717,6 +717,55 @@ The reservation save is the admission point. A save failure produces no start ev
|
|
|
717
717
|
|
|
718
718
|
`abort()` returns `true` only while cancellation is still accepted. Terminal persistence is the run's commit point; once it starts, `abort()` returns `false` and the already-fixed terminal outcome completes while the session remains busy.
|
|
719
719
|
|
|
720
|
+
### Continuing Without New Input
|
|
721
|
+
|
|
722
|
+
Use `continuePrompt()` after a failed or stopped run whose input remains in context
|
|
723
|
+
(`keptInContext: true`), when the conversation still needs an answer:
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
const continuation = await harness.continuePrompt(scopeId, sessionId, {
|
|
727
|
+
modelHint: 'available-model',
|
|
728
|
+
});
|
|
729
|
+
console.log(continuation.queueId);
|
|
730
|
+
const result = await continuation.completion;
|
|
731
|
+
console.log(result.assistantMessage.parts);
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
The signature is `continuePrompt(scopeId: string, sessionId: string,
|
|
735
|
+
options?: IFlexContinuePromptOptions): Promise<IFlexPromptQueueAdmission<IFlexContinuePromptResult>>`.
|
|
736
|
+
It accepts `modelHint`, `system`, `maxSteps` and initial `steers`, plus optional
|
|
737
|
+
`scheduleKey` and `debounceMs` for the same scheduling behavior as `schedulePrompt()`.
|
|
738
|
+
`debounceMs` requires a schedule key and defaults to 50 ms when scheduled. There is
|
|
739
|
+
no `prompt` or `promptId`: continuation creates a new run and an assistant message
|
|
740
|
+
without appending or modifying a user message. Its result has the prompt result's
|
|
741
|
+
fields except `userMessage`. Explicit steers still become user messages normally.
|
|
742
|
+
|
|
743
|
+
Admission returns `{ queueId, completion }` once queued, like `enqueuePrompt()`.
|
|
744
|
+
The entry and its `prompt.*` events carry `continuation: true`. Inspect its `runId`
|
|
745
|
+
after `prompt.started`, or read it through `getPromptQueueEntry()`. Use the same
|
|
746
|
+
`cancelPrompt()`, `cancelScheduledPrompt()`, `abort()` and `steerPrompt()` methods.
|
|
747
|
+
Queue bounds and FIFO order, permissions, resource cleanup, compaction, usage
|
|
748
|
+
accounting and provider limits follow the ordinary run path.
|
|
749
|
+
|
|
750
|
+
At the front of the FIFO, the harness validates the visible transcript and model
|
|
751
|
+
context with undone and excluded runs filtered out, before committing an undone
|
|
752
|
+
branch or reserving a generation or audit message. A refusal preserves redo history.
|
|
753
|
+
Empty context, an already answered turn, and an unsuccessful input that did not stay
|
|
754
|
+
in context refuse with `FlexHarnessValidationError` through `completion`. A retained
|
|
755
|
+
failure whose model context ends in partial assistant text with no tool call is also
|
|
756
|
+
refused; `keptInContext: true` alone does not guarantee it can be continued. An earlier
|
|
757
|
+
queued answer can therefore invalidate a waiting continuation. Cancellation before
|
|
758
|
+
execution leaves the retained input available for another continuation. Continuing never replays
|
|
759
|
+
previous tool calls; unknown external effects still require reconciliation.
|
|
760
|
+
|
|
761
|
+
Existing projections and Agent event stores remain readable without migration.
|
|
762
|
+
Assistant-only runs require two optional fields in the existing schema-3 projection:
|
|
763
|
+
a staged terminal omits `userMessage`, and its reversion segment omits
|
|
764
|
+
`userMessageId`. Requiring either would invent a user message. Generation claims
|
|
765
|
+
use existing events without a new `user-message` event. Older strict projection
|
|
766
|
+
readers cannot open a session containing these continuation records; do not mix
|
|
767
|
+
versions or downgrade those sessions.
|
|
768
|
+
|
|
720
769
|
### Steering a Running Turn
|
|
721
770
|
|
|
722
771
|
`steerPrompt()` hands a message to the running run of a prompt instead of queueing it behind that run:
|
|
@@ -1010,7 +1059,7 @@ if (started.status === 'started') {
|
|
|
1010
1059
|
|
|
1011
1060
|
**Read-only.** An imported session never runs, and nothing writes model context for it: it has no Agent events and no tool jobs. Every operation that would change its history or model context throws `FlexHarnessImportedSessionError` (code `FLEX_SESSION_READ_ONLY`):
|
|
1012
1061
|
|
|
1013
|
-
- `prompt`, `startPrompt`, `enqueuePrompt` and `
|
|
1062
|
+
- `prompt`, `startPrompt`, `enqueuePrompt`, `schedulePrompt` and `continuePrompt`;
|
|
1014
1063
|
- `executeSlashCommand` and `compactSession`;
|
|
1015
1064
|
- `undoSession` and `redoSession`;
|
|
1016
1065
|
- `pushRuntimeEvent`, `reconcileToolExecution` and `archiveSessionEvents`;
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import type {
|
|
9
9
|
IFlexCacheWarmInput,
|
|
10
10
|
IFlexCacheWarmState,
|
|
11
|
-
|
|
11
|
+
IFlexContinuePromptResult,
|
|
12
12
|
IFlexSession,
|
|
13
13
|
} from './interfaces.js';
|
|
14
14
|
import type {
|
|
@@ -236,7 +236,7 @@ export class FlexHarnessCacheWarm<TScope> {
|
|
|
236
236
|
queued: IQueuedPrompt,
|
|
237
237
|
status: 'completed' | 'failed' | 'cancelled',
|
|
238
238
|
error?: unknown,
|
|
239
|
-
result?:
|
|
239
|
+
result?: IFlexContinuePromptResult,
|
|
240
240
|
): void {
|
|
241
241
|
const entry = this.entries.get(this.key(queued.state.storageKey, queued.sessionId));
|
|
242
242
|
if (queued.cacheWarm && status === 'completed' && result) {
|
|
@@ -542,7 +542,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
542
542
|
storageKey: string,
|
|
543
543
|
compactorLifecycleController = new AbortController(),
|
|
544
544
|
): IStoredSessionState {
|
|
545
|
-
const unavailable = new Proxy({} as plugins.
|
|
545
|
+
const unavailable = new Proxy({} as plugins.IAgentContinuationSession, {
|
|
546
546
|
get() {
|
|
547
547
|
throw new Error('AgentSession is not initialized.');
|
|
548
548
|
},
|
|
@@ -699,7 +699,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
699
699
|
permissionRevision: permission?.revision ?? 0,
|
|
700
700
|
permissionQueue: Promise.resolve(),
|
|
701
701
|
eventStore,
|
|
702
|
-
agentSession: undefined as unknown as plugins.
|
|
702
|
+
agentSession: undefined as unknown as plugins.IAgentContinuationSession,
|
|
703
703
|
agentSessionAbortCompleted: false,
|
|
704
704
|
agentSessionCloseCompleted: false,
|
|
705
705
|
agentEventStoreReleased: this.stores.agentEvents.releaseSession === undefined,
|
|
@@ -979,7 +979,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
979
979
|
const stage = stages.get(runId);
|
|
980
980
|
if (outcome === 'accepted' && stage?.status === 'completed') {
|
|
981
981
|
this.runs.replaceVisibleRun(stored, this.runs.withKeptInContext(stage, true));
|
|
982
|
-
if (!stage.
|
|
982
|
+
if (!stage.assistantMessage.cacheWarm) latestTerminal = stage;
|
|
983
983
|
} else if (outcome !== 'accepted') {
|
|
984
984
|
const visible = stored.messages.filter((message) => message.runId === runId);
|
|
985
985
|
// A run that failed or was stopped keeps its terminal and its error: the staged one, or the
|
|
@@ -994,7 +994,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
994
994
|
plugins.isGenerationInModelContext(agentEvents, runId),
|
|
995
995
|
));
|
|
996
996
|
}
|
|
997
|
-
if (!ended.
|
|
997
|
+
if (!ended.assistantMessage.cacheWarm) latestTerminal = ended;
|
|
998
998
|
} else if (visible.length > 0 || stage) {
|
|
999
999
|
const status = outcome === 'rejected' ? 'failed' : 'cancelled';
|
|
1000
1000
|
const terminal = this.runs.withKeptInContext(
|
|
@@ -1002,7 +1002,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
1002
1002
|
plugins.isGenerationInModelContext(agentEvents, runId),
|
|
1003
1003
|
);
|
|
1004
1004
|
this.runs.replaceVisibleRun(stored, terminal);
|
|
1005
|
-
if (!terminal.
|
|
1005
|
+
if (!terminal.assistantMessage.cacheWarm) latestTerminal = terminal;
|
|
1006
1006
|
}
|
|
1007
1007
|
} else {
|
|
1008
1008
|
const visibleSuccess = stored.messages.some((message) =>
|
|
@@ -1015,7 +1015,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
1015
1015
|
undefined,
|
|
1016
1016
|
'completed',
|
|
1017
1017
|
);
|
|
1018
|
-
if (!terminal.
|
|
1018
|
+
if (!terminal.assistantMessage.cacheWarm) latestTerminal = terminal;
|
|
1019
1019
|
}
|
|
1020
1020
|
}
|
|
1021
1021
|
}
|
|
@@ -1120,13 +1120,13 @@ export class FlexHarnessCore<TScope> {
|
|
|
1120
1120
|
if (visible.some((message) => message.status === 'streaming')) return undefined;
|
|
1121
1121
|
const userMessage = visible.find((message) => message.role === 'user' && message.steerId === undefined);
|
|
1122
1122
|
const assistantMessage = [...visible].reverse().find((message) => message.role === 'assistant');
|
|
1123
|
-
if (!
|
|
1123
|
+
if (!assistantMessage) return undefined;
|
|
1124
1124
|
const status = assistantMessage.status;
|
|
1125
1125
|
if (status !== 'failed' && status !== 'cancelled') return undefined;
|
|
1126
1126
|
return {
|
|
1127
1127
|
runId,
|
|
1128
1128
|
status,
|
|
1129
|
-
userMessage,
|
|
1129
|
+
...(userMessage ? { userMessage } : {}),
|
|
1130
1130
|
assistantMessage,
|
|
1131
1131
|
...(assistantMessage.model ? { model: assistantMessage.model } : {}),
|
|
1132
1132
|
...(assistantMessage.usage ? { usage: assistantMessage.usage } : {}),
|
|
@@ -1147,15 +1147,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
1147
1147
|
?? visible.find((message) => message.role === 'user' && message.steerId === undefined);
|
|
1148
1148
|
const sourceAssistant = stage?.assistantMessage
|
|
1149
1149
|
?? [...visible].reverse().find((message) => message.role === 'assistant');
|
|
1150
|
-
const userMessage
|
|
1151
|
-
messageId: plugins.crypto.randomUUID(),
|
|
1152
|
-
sessionId,
|
|
1153
|
-
runId,
|
|
1154
|
-
role: 'user',
|
|
1155
|
-
status,
|
|
1156
|
-
createdAt: timestamp,
|
|
1157
|
-
parts: [],
|
|
1158
|
-
});
|
|
1150
|
+
const userMessage = sourceUser ? cloneSerializable(sourceUser) : undefined;
|
|
1159
1151
|
const assistantMessage: IFlexMessage = cloneSerializable(sourceAssistant ?? {
|
|
1160
1152
|
messageId: plugins.crypto.randomUUID(),
|
|
1161
1153
|
sessionId,
|
|
@@ -1165,9 +1157,9 @@ export class FlexHarnessCore<TScope> {
|
|
|
1165
1157
|
createdAt: timestamp,
|
|
1166
1158
|
parts: [],
|
|
1167
1159
|
});
|
|
1168
|
-
userMessage.status = status;
|
|
1160
|
+
if (userMessage) userMessage.status = status;
|
|
1169
1161
|
assistantMessage.status = status;
|
|
1170
|
-
userMessage.completedAt ??= timestamp;
|
|
1162
|
+
if (userMessage) userMessage.completedAt ??= timestamp;
|
|
1171
1163
|
assistantMessage.completedAt ??= timestamp;
|
|
1172
1164
|
for (const part of assistantMessage.parts) {
|
|
1173
1165
|
if (part.type === 'reasoning' && part.status === 'running') {
|
|
@@ -1183,7 +1175,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
1183
1175
|
if (status !== 'completed') {
|
|
1184
1176
|
const message = status === 'cancelled' ? repairCancellationMessage : externalErrorFallback.message;
|
|
1185
1177
|
// As for a run that ends live: a failed run marks its prompt too, a cancelled one only its answer.
|
|
1186
|
-
if (status === 'failed') userMessage.error = message;
|
|
1178
|
+
if (status === 'failed' && userMessage) userMessage.error = message;
|
|
1187
1179
|
assistantMessage.error = message;
|
|
1188
1180
|
if (stage?.usage) {
|
|
1189
1181
|
assistantMessage.usage = cloneSerializable(stage.usage);
|
|
@@ -1194,7 +1186,7 @@ export class FlexHarnessCore<TScope> {
|
|
|
1194
1186
|
return {
|
|
1195
1187
|
runId,
|
|
1196
1188
|
status,
|
|
1197
|
-
userMessage,
|
|
1189
|
+
...(userMessage ? { userMessage } : {}),
|
|
1198
1190
|
assistantMessage,
|
|
1199
1191
|
...(status === 'completed' && stage?.model ? { model: stage.model } : {}),
|
|
1200
1192
|
...(stage?.usage ? { usage: stage.usage } : {}),
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
} from './errors.js';
|
|
14
14
|
import type {
|
|
15
15
|
IFlexAgentContextInvocation,
|
|
16
|
+
IFlexContinuePromptOptions,
|
|
17
|
+
IFlexContinuePromptResult,
|
|
16
18
|
IFlexEnqueuePromptOptions,
|
|
17
19
|
IFlexSteerAdmission,
|
|
18
20
|
IFlexSteerInput,
|
|
@@ -24,6 +26,7 @@ import type {
|
|
|
24
26
|
IFlexScheduledPromptAdmission,
|
|
25
27
|
IFlexSchedulePromptOptions,
|
|
26
28
|
TFlexPrompt,
|
|
29
|
+
TFlexAgentPrompt,
|
|
27
30
|
} from './interfaces.js';
|
|
28
31
|
import { cloneSerializable } from './utils.json.js';
|
|
29
32
|
import { normalizeFlexPrompt, type INormalizedFlexPrompt } from './utils.prompt.js';
|
|
@@ -71,13 +74,15 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
71
74
|
constructor(private readonly core: FlexHarnessCore<TScope>) {}
|
|
72
75
|
|
|
73
76
|
public promptAdmissionByteSize(
|
|
74
|
-
prompt: TFlexPrompt,
|
|
77
|
+
prompt: TFlexPrompt | undefined,
|
|
75
78
|
options: IFlexPromptOptions,
|
|
76
79
|
scheduleKey?: string,
|
|
77
80
|
debounceMs?: number,
|
|
78
81
|
): number {
|
|
79
82
|
return jsonBytes({
|
|
80
|
-
|
|
83
|
+
...(prompt === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { prompt: normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols) }),
|
|
81
86
|
options: cloneSerializable(options),
|
|
82
87
|
scheduleKey,
|
|
83
88
|
debounceMs,
|
|
@@ -100,12 +105,52 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
100
105
|
return admission.completion;
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
public async continuePrompt(
|
|
109
|
+
scopeId: string,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
options: IFlexContinuePromptOptions = {},
|
|
112
|
+
): Promise<IFlexPromptQueueAdmission<IFlexContinuePromptResult>> {
|
|
113
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
114
|
+
throw new FlexHarnessValidationError('Continuation options must be a plain object.');
|
|
115
|
+
}
|
|
116
|
+
const { scheduleKey, debounceMs, ...enqueueOptions } = options;
|
|
117
|
+
if ('promptId' in enqueueOptions) {
|
|
118
|
+
throw new FlexHarnessValidationError('A continuation has no new prompt to identify.');
|
|
119
|
+
}
|
|
120
|
+
const admitted = this.splitEnqueueOptions(enqueueOptions);
|
|
121
|
+
if (scheduleKey === undefined && debounceMs !== undefined) {
|
|
122
|
+
throw new FlexHarnessValidationError('debounceMs requires scheduleKey.');
|
|
123
|
+
}
|
|
124
|
+
if (scheduleKey !== undefined) {
|
|
125
|
+
validateIdentifier(scheduleKey, 'scheduleKey');
|
|
126
|
+
requireTransferIdentifier(scheduleKey, 'scheduleKey');
|
|
127
|
+
this.validateScheduleDebounce(debounceMs ?? 50);
|
|
128
|
+
}
|
|
129
|
+
const queued = await this.enqueuePromptInternal(
|
|
130
|
+
scopeId, sessionId, undefined, admitted.options,
|
|
131
|
+
scheduleKey, scheduleKey === undefined ? undefined : debounceMs ?? 50,
|
|
132
|
+
undefined, undefined, false, false, admitted.steers,
|
|
133
|
+
);
|
|
134
|
+
return queued.admission;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private validateScheduleDebounce(debounceMs: number): void {
|
|
138
|
+
if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
|
|
139
|
+
throw new FlexHarnessValidationError(
|
|
140
|
+
`debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
public async startPrompt(
|
|
104
146
|
scopeId: string,
|
|
105
147
|
sessionId: string,
|
|
106
148
|
prompt: TFlexPrompt,
|
|
107
149
|
options: IFlexEnqueuePromptOptions = {},
|
|
108
150
|
): Promise<IFlexPromptAdmission> {
|
|
151
|
+
if (prompt === undefined) {
|
|
152
|
+
throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
|
|
153
|
+
}
|
|
109
154
|
const admitted = this.splitEnqueueOptions(options);
|
|
110
155
|
const queued = await this.enqueuePromptInternal(
|
|
111
156
|
scopeId, sessionId, prompt, admitted.options,
|
|
@@ -120,6 +165,9 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
120
165
|
prompt: TFlexPrompt,
|
|
121
166
|
options: IFlexEnqueuePromptOptions = {},
|
|
122
167
|
): Promise<IFlexPromptQueueAdmission> {
|
|
168
|
+
if (prompt === undefined) {
|
|
169
|
+
throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
|
|
170
|
+
}
|
|
123
171
|
const admitted = this.splitEnqueueOptions(options);
|
|
124
172
|
const queued = await this.enqueuePromptInternal(
|
|
125
173
|
scopeId, sessionId, prompt, admitted.options,
|
|
@@ -166,15 +214,14 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
166
214
|
prompt: TFlexPrompt,
|
|
167
215
|
options: IFlexSchedulePromptOptions = {},
|
|
168
216
|
): Promise<IFlexScheduledPromptAdmission> {
|
|
217
|
+
if (prompt === undefined) {
|
|
218
|
+
throw new FlexHarnessValidationError('prompt must be a string or a non-empty array of parts.');
|
|
219
|
+
}
|
|
169
220
|
validateIdentifier(scheduleKey, 'scheduleKey');
|
|
170
221
|
requireTransferIdentifier(scheduleKey, 'scheduleKey');
|
|
171
222
|
validatePromptOptions(options, true);
|
|
172
223
|
const debounceMs = options.debounceMs ?? 50;
|
|
173
|
-
|
|
174
|
-
throw new FlexHarnessValidationError(
|
|
175
|
-
`debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
|
|
176
|
-
);
|
|
177
|
-
}
|
|
224
|
+
this.validateScheduleDebounce(debounceMs);
|
|
178
225
|
const queued = await this.enqueuePromptInternal(
|
|
179
226
|
scopeId,
|
|
180
227
|
sessionId,
|
|
@@ -331,9 +378,7 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
331
378
|
try {
|
|
332
379
|
steering.input.add({
|
|
333
380
|
inputId: steer.steerId,
|
|
334
|
-
content: cloneSerializable(prompt.modelMessage.content) as
|
|
335
|
-
plugins.IAgentSession['beginGeneration']
|
|
336
|
-
>[0],
|
|
381
|
+
content: cloneSerializable(prompt.modelMessage.content) as TFlexAgentPrompt,
|
|
337
382
|
});
|
|
338
383
|
} catch (error) {
|
|
339
384
|
if (error instanceof plugins.AgentPendingInputRejectedError) {
|
|
@@ -355,10 +400,10 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
355
400
|
return { steerId: steer.steerId, queueId: run.queueId, runId: run.runId };
|
|
356
401
|
}
|
|
357
402
|
|
|
358
|
-
public async enqueuePromptInternal(
|
|
403
|
+
public async enqueuePromptInternal<TPrompt extends TFlexPrompt | undefined>(
|
|
359
404
|
scopeId: string,
|
|
360
405
|
sessionId: string,
|
|
361
|
-
prompt:
|
|
406
|
+
prompt: TPrompt,
|
|
362
407
|
options: IFlexPromptOptions,
|
|
363
408
|
scheduleKey?: string,
|
|
364
409
|
debounceMs?: number,
|
|
@@ -368,13 +413,15 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
368
413
|
cacheWarm = false,
|
|
369
414
|
steers: readonly IAdmittedSteer[] = [],
|
|
370
415
|
): Promise<{
|
|
371
|
-
admission: IFlexPromptQueueAdmission
|
|
372
|
-
started: Promise<IFlexPromptAdmission
|
|
416
|
+
admission: IFlexPromptQueueAdmission<TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult>;
|
|
417
|
+
started: Promise<IFlexPromptAdmission<TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult>>;
|
|
373
418
|
queued?: IQueuedPrompt;
|
|
374
419
|
}> {
|
|
375
420
|
this.core.assertOpen();
|
|
376
421
|
if (options.promptId !== undefined) requireTransferIdentifier(options.promptId, 'promptId');
|
|
377
|
-
const normalizedPrompt =
|
|
422
|
+
const normalizedPrompt = prompt === undefined
|
|
423
|
+
? undefined
|
|
424
|
+
: normalizeFlexPrompt(prompt, this.core.attachmentReferenceProtocols);
|
|
378
425
|
const normalizedOptions = cloneSerializable(options);
|
|
379
426
|
const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
|
|
380
427
|
const steerBytes = steers.reduce((sum, steer) => sum + steer.byteSize, 0);
|
|
@@ -467,16 +514,16 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
467
514
|
) {
|
|
468
515
|
throw new FlexHarnessDuplicatePromptError(options.promptId);
|
|
469
516
|
}
|
|
470
|
-
let resolveCompletion!: (result:
|
|
517
|
+
let resolveCompletion!: (result: IFlexContinuePromptResult) => void;
|
|
471
518
|
let rejectCompletion!: (error: unknown) => void;
|
|
472
|
-
const completion = new Promise<
|
|
519
|
+
const completion = new Promise<IFlexContinuePromptResult>((resolve, reject) => {
|
|
473
520
|
resolveCompletion = resolve;
|
|
474
521
|
rejectCompletion = reject;
|
|
475
522
|
});
|
|
476
523
|
void completion.catch(() => undefined);
|
|
477
|
-
let resolveStarted!: (admission: IFlexPromptAdmission) => void;
|
|
524
|
+
let resolveStarted!: (admission: IFlexPromptAdmission<IFlexContinuePromptResult>) => void;
|
|
478
525
|
let rejectStarted!: (error: unknown) => void;
|
|
479
|
-
const started = new Promise<IFlexPromptAdmission
|
|
526
|
+
const started = new Promise<IFlexPromptAdmission<IFlexContinuePromptResult>>((resolve, reject) => {
|
|
480
527
|
resolveStarted = resolve;
|
|
481
528
|
rejectStarted = reject;
|
|
482
529
|
});
|
|
@@ -494,7 +541,7 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
494
541
|
...(scheduleKey === undefined ? {} : { scheduleKey, debounceMs }),
|
|
495
542
|
...(cacheWarm ? { cacheWarm: true as const } : {}),
|
|
496
543
|
...(options.promptId === undefined ? {} : { promptId: options.promptId }),
|
|
497
|
-
prompt: normalizedPrompt,
|
|
544
|
+
...(normalizedPrompt === undefined ? { continuation: true as const } : { prompt: normalizedPrompt }),
|
|
498
545
|
options: normalizedOptions,
|
|
499
546
|
...(delegationSeed === undefined ? {} : { delegationSeed }),
|
|
500
547
|
byteSize,
|
|
@@ -517,8 +564,13 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
517
564
|
this.emitPromptQueueEvent(queued, 'prompt.queued');
|
|
518
565
|
this.schedulePromptQueueDrain(state, stored);
|
|
519
566
|
return {
|
|
520
|
-
|
|
521
|
-
|
|
567
|
+
// The input determines whether the completed run has a user message.
|
|
568
|
+
admission: Object.freeze({ queueId: queued.queueId, completion }) as IFlexPromptQueueAdmission<
|
|
569
|
+
TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult
|
|
570
|
+
>,
|
|
571
|
+
started: started as Promise<IFlexPromptAdmission<
|
|
572
|
+
TPrompt extends undefined ? IFlexContinuePromptResult : IFlexPromptResult
|
|
573
|
+
>>,
|
|
522
574
|
queued,
|
|
523
575
|
};
|
|
524
576
|
} finally {
|
|
@@ -625,8 +677,28 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
625
677
|
}
|
|
626
678
|
}
|
|
627
679
|
|
|
680
|
+
/** Check the actual model context at the FIFO boundary, before any durable run claim. */
|
|
681
|
+
private assertContinuableContext(stored: IStoredSessionState): void {
|
|
682
|
+
const visibleEvents = this.core.reversions.filterReversionContextEvents(stored, stored.agentSession.getEvents());
|
|
683
|
+
const retainedRunIds = new Set(plugins.filterModelVisibleAgentEvents(visibleEvents)
|
|
684
|
+
.map((event) => event.generationId));
|
|
685
|
+
const lastTurnMessage = [...this.core.visibleMessages(stored)].reverse().find((message) =>
|
|
686
|
+
!message.cacheWarm && (message.status === 'completed'
|
|
687
|
+
|| message.keptInContext === true || retainedRunIds.has(message.runId)));
|
|
688
|
+
// A summary or runtime update can look like a user message even after a completed answer.
|
|
689
|
+
const answered = !lastTurnMessage || (lastTurnMessage.role === 'assistant'
|
|
690
|
+
&& lastTurnMessage.status === 'completed');
|
|
691
|
+
const last = answered ? undefined : stored.agentSession.getModelMessages().at(-1);
|
|
692
|
+
if (last?.role === 'user' || last?.role === 'tool') return;
|
|
693
|
+
if (last?.role === 'assistant' && Array.isArray(last.content)
|
|
694
|
+
&& last.content.some((part) => part.type === 'tool-call')) return;
|
|
695
|
+
throw this.core.trustInternalError(new FlexHarnessValidationError(
|
|
696
|
+
'The conversation has no unanswered turn to continue.',
|
|
697
|
+
));
|
|
698
|
+
}
|
|
699
|
+
|
|
628
700
|
private async promoteQueuedPrompt(queued: IQueuedPrompt, run: IActiveRun): Promise<void> {
|
|
629
|
-
const prompt = queued.prompt
|
|
701
|
+
const prompt = queued.prompt;
|
|
630
702
|
const options = queued.options!;
|
|
631
703
|
let projectionReserved = false;
|
|
632
704
|
// Open from the reservation until its messages are announced or rolled back.
|
|
@@ -636,18 +708,18 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
636
708
|
this.assertPromptPromotion(queued, run);
|
|
637
709
|
await this.core.admission.acquireDelegatedRunAdmission(queued, run);
|
|
638
710
|
this.assertPromptPromotion(queued, run);
|
|
711
|
+
if (queued.continuation) this.assertContinuableContext(run.stored);
|
|
639
712
|
await this.core.reversions.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
|
|
640
713
|
this.assertPromptPromotion(queued, run);
|
|
641
|
-
const generationPrompt =
|
|
642
|
-
|
|
643
|
-
|
|
714
|
+
const generationPrompt = prompt === undefined
|
|
715
|
+
? undefined
|
|
716
|
+
: cloneSerializable(prompt.modelMessage.content) as TFlexAgentPrompt;
|
|
644
717
|
this.assertPromptPromotion(queued, run);
|
|
645
718
|
run.transaction = await this.core.compaction.withRunCompactorContext(
|
|
646
719
|
run,
|
|
647
|
-
() =>
|
|
648
|
-
|
|
649
|
-
{ generationId: run.runId },
|
|
650
|
-
),
|
|
720
|
+
() => generationPrompt === undefined
|
|
721
|
+
? run.stored.agentSession.beginContinuation({ generationId: run.runId })
|
|
722
|
+
: run.stored.agentSession.beginGeneration(generationPrompt, { generationId: run.runId }),
|
|
651
723
|
);
|
|
652
724
|
this.assertPromptPromotion(queued, run);
|
|
653
725
|
const reservation = this.core.runs.createReservation(run, prompt, queued.promptId);
|
|
@@ -658,7 +730,8 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
658
730
|
throw new Error('Session metrics already owns a generation.');
|
|
659
731
|
}
|
|
660
732
|
run.stored.metrics.pendingGenerationId = run.runId;
|
|
661
|
-
run.stored.messages.push(reservation.userMessage
|
|
733
|
+
if (reservation.userMessage) run.stored.messages.push(reservation.userMessage);
|
|
734
|
+
run.stored.messages.push(reservation.assistantMessage);
|
|
662
735
|
// A cache-warm turn is no turn of the user's: it gets no reversion segment of its own.
|
|
663
736
|
if (run.stored.session.agent === undefined && !run.cacheWarm) {
|
|
664
737
|
this.core.reversions.pruneReversionState(run.stored, true);
|
|
@@ -668,7 +741,7 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
668
741
|
const protocolVersion = this.core.reversions.reversionProviderProtocolVersion() ?? 1;
|
|
669
742
|
run.stored.reversionSegments.push({
|
|
670
743
|
runId: run.runId,
|
|
671
|
-
userMessageId: reservation.userMessage.messageId,
|
|
744
|
+
...(reservation.userMessage ? { userMessageId: reservation.userMessage.messageId } : {}),
|
|
672
745
|
status: 'capturing',
|
|
673
746
|
contextAvailable: true,
|
|
674
747
|
eventIds: [],
|
|
@@ -690,7 +763,7 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
690
763
|
}
|
|
691
764
|
});
|
|
692
765
|
projectionReserved = true;
|
|
693
|
-
run.reservedUserMessage = publicSnapshot(reservation.userMessage);
|
|
766
|
+
if (reservation.userMessage) run.reservedUserMessage = publicSnapshot(reservation.userMessage);
|
|
694
767
|
run.reservedAssistantMessage = publicSnapshot(reservation.assistantMessage);
|
|
695
768
|
this.assertPromptPromotion(queued, run);
|
|
696
769
|
await this.core.mutateScope(run.state, () => {
|
|
@@ -707,9 +780,9 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
707
780
|
run.stored.session.activity = {
|
|
708
781
|
runId: run.runId,
|
|
709
782
|
status: run.scheduleKey ? 'scheduled' : 'running',
|
|
710
|
-
startedAt: reservation.
|
|
783
|
+
startedAt: reservation.assistantMessage.createdAt,
|
|
711
784
|
};
|
|
712
|
-
run.stored.session.updatedAt = reservation.
|
|
785
|
+
run.stored.session.updatedAt = reservation.assistantMessage.createdAt;
|
|
713
786
|
const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
|
|
714
787
|
if (preview !== undefined) run.stored.session.preview = preview;
|
|
715
788
|
});
|
|
@@ -751,11 +824,12 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
751
824
|
this.core.emitEvent(run.scopeId, run.stored.session, {
|
|
752
825
|
type: runType,
|
|
753
826
|
runId: run.runId,
|
|
754
|
-
messageId: run.userMessageId,
|
|
827
|
+
messageId: run.userMessageId ?? run.assistantMessageId,
|
|
755
828
|
session: publicSnapshot(run.stored.session),
|
|
756
829
|
...(run.cacheWarm ? { cacheWarm: true } : {}),
|
|
757
830
|
});
|
|
758
|
-
for (const message of [run.reservedUserMessage
|
|
831
|
+
for (const message of [run.reservedUserMessage, run.reservedAssistantMessage!]) {
|
|
832
|
+
if (!message) continue;
|
|
759
833
|
this.core.emitEvent(run.scopeId, run.stored.session, {
|
|
760
834
|
type: 'message.created',
|
|
761
835
|
runId: run.runId,
|
|
@@ -828,6 +902,7 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
828
902
|
queuedAt: queued.queuedAt,
|
|
829
903
|
...(queued.runId ? { runId: queued.runId } : {}),
|
|
830
904
|
...(queued.promptId === undefined ? {} : { promptId: queued.promptId }),
|
|
905
|
+
...(queued.continuation ? { continuation: true as const } : {}),
|
|
831
906
|
...(queued.scheduleKey ? { scheduleKey: queued.scheduleKey } : {}),
|
|
832
907
|
...(queued.cacheWarm ? { cacheWarm: true } : {}),
|
|
833
908
|
...(queued.startedAt ? { startedAt: queued.startedAt } : {}),
|
|
@@ -861,7 +936,7 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
861
936
|
queued: IQueuedPrompt,
|
|
862
937
|
status: 'completed' | 'failed' | 'cancelled',
|
|
863
938
|
error?: unknown,
|
|
864
|
-
result?:
|
|
939
|
+
result?: IFlexContinuePromptResult,
|
|
865
940
|
): void {
|
|
866
941
|
const stored = queued.stored;
|
|
867
942
|
if (stored.outstandingPromptsById.get(queued.queueId) !== queued) return;
|