@modelprofile.com/flexharness 12.4.0 → 13.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.callbacks.js +10 -3
- package/dist_ts/classes.flexharness.core.js +4 -2
- package/dist_ts/classes.flexharness.permissions.js +11 -3
- package/dist_ts/classes.flexharness.prompts.js +11 -2
- package/dist_ts/classes.flexharness.reversions.js +21 -6
- package/dist_ts/classes.flexharness.runs.js +13 -7
- package/dist_ts/classes.flexharness.subagents.js +4 -5
- package/dist_ts/errors.d.ts +8 -2
- package/dist_ts/errors.js +13 -5
- package/dist_ts/interfaces.d.ts +27 -0
- package/dist_ts/interfaces.flexharness.d.ts +3 -1
- package/dist_ts/interfaces.js +7 -1
- package/dist_ts/utils.flexharness.d.ts +8 -2
- package/dist_ts/utils.flexharness.js +13 -3
- package/dist_ts/utils.json.js +9 -2
- package/dist_ts/utils.sessionpreview.d.ts +7 -0
- package/dist_ts/utils.sessionpreview.js +35 -0
- package/package.json +3 -3
- package/readme.md +8 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.callbacks.ts +9 -3
- package/ts/classes.flexharness.core.ts +2 -1
- package/ts/classes.flexharness.permissions.ts +8 -2
- package/ts/classes.flexharness.prompts.ts +9 -1
- package/ts/classes.flexharness.reversions.ts +21 -5
- package/ts/classes.flexharness.runs.ts +11 -4
- package/ts/classes.flexharness.subagents.ts +3 -3
- package/ts/errors.ts +13 -4
- package/ts/interfaces.flexharness.ts +3 -0
- package/ts/interfaces.ts +33 -0
- package/ts/readme.md +8 -4
- package/ts/utils.flexharness.ts +16 -2
- package/ts/utils.json.ts +12 -1
- package/ts/utils.sessionpreview.ts +32 -0
package/readme.md
CHANGED
|
@@ -693,7 +693,7 @@ await harness.dispose();
|
|
|
693
693
|
|
|
694
694
|
Only one run may be active in a session. Additional prompts enter a bounded FIFO owned by FlexHarness, while different sessions can run concurrently. `enqueuePrompt()` resolves with `{ queueId, completion }` after the immutable prompt and options have been accepted into that runtime queue and `prompt.queued` has been emitted. `startPrompt()` keeps its durable-admission behavior: it waits for its FIFO turn and resolves with `{ queueId, runId, completion }` only after the canonical generation claim, run ID, and initial public audit messages have been durably reserved and the corresponding start events have been emitted. `prompt()` preserves the simpler behavior by awaiting completion internally.
|
|
695
695
|
|
|
696
|
-
Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
|
|
696
|
+
Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path and is stopped with the same `FlexHarnessAbortError` ("The run was aborted.") as `abort()`; only a waiting entry is rejected with "The queued prompt was cancelled.". A cancelled run, whether stopped or found interrupted on reload, puts its `error` on the assistant message only; its user message is `cancelled` without an `error`, while a failed run marks both messages. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
|
|
697
697
|
|
|
698
698
|
The displayed queue limits are the defaults. Outstanding count and byte limits apply per session and include every non-terminal queued or active prompt until it settles. Pending-admission limits apply to the complete harness while scope aliases are unresolved. Terminal retention applies per session. Exceeding an admission limit throws `FlexHarnessQueueFullError`.
|
|
699
699
|
|
|
@@ -705,7 +705,7 @@ The reservation save is the admission point. A save failure produces no start ev
|
|
|
705
705
|
|
|
706
706
|
`listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. Each page carries `startIndex`, the zero-based index of its first message in the session's visible history (for an empty page, the index the page ends at), so a consumer can order and page by absolute position. Appending messages leaves every index unchanged; a branch after an undo reuses the positions of the history it replaces, so a consumer re-reads its pages after `session.history.changed`. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
|
|
707
707
|
|
|
708
|
-
`updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
|
|
708
|
+
`updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Every session exposes `preview` once a run with prompt text was admitted: its first prompt as one line (whitespace runs collapsed, cut at a character boundary to `maxTitleBytes`), with no model call, so a host can name a session nobody titled (`title || preview`). It is set once, a session stored before previews existed takes it from its first prompt at its next run, and no caller sets or clears it. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
|
|
709
709
|
|
|
710
710
|
`deleteSessionGenerationCohort()` is the generation-fenced destructive form for controllers. Its `root` and every `authorizedCohort` entry use the exact `{ sessionId, sessionGenerationId, sessionGenerationSequence }` shape. The cohort contains unique entries in strictly ascending `sessionId` order and is limited by `FLEX_SESSION_GENERATION_COHORT_MAX_ENTRIES` (2048). FlexHarness atomically validates the matching root, its complete live subtree, and every retained descendant tombstone group before reserving deletion. A missing or different root generation returns `{ matched: false }` without touching a newer generation; a missing or mismatched cascade entry throws before new tombstoning or cleanup. Extra valid entries do not expand the cascade. A matched partial cleanup remains retryable with the same authority and resolves to `{ matched: true }` when cleanup completes. The existing `deleteSession()` method remains available for callers that intentionally own the complete reusable-ID namespace.
|
|
711
711
|
|
|
@@ -872,11 +872,13 @@ for (const group of info.groups) {
|
|
|
872
872
|
group.visibility,
|
|
873
873
|
group.affectedWorkspaces,
|
|
874
874
|
group.affectedWorkspacesTruncated,
|
|
875
|
+
group.barrierCauses,
|
|
876
|
+
group.reasonCodes,
|
|
875
877
|
);
|
|
876
878
|
}
|
|
877
879
|
```
|
|
878
880
|
|
|
879
|
-
The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. It never includes capture IDs or provider references.
|
|
881
|
+
The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. `barrierCauses` states why a barrier blocks traversal, each cause once in this order: `pending` while a capture runs or its outcome is not final, `nonrevertible` for a workspace outcome that cannot be reverted, `transcript-only` for legacy transcript history without a workspace capture; it is empty for candidate and no-change groups (`FLEX_SESSION_REVERSION_BARRIER_CAUSES` lists the order). `reasonCodes` lists the distinct `reasonCode`s of the group's nonrevertible segments in segment order, so an application can say why a turn is not revertible even when its provider named no affected workspace. It never includes capture IDs or provider references.
|
|
880
882
|
|
|
881
883
|
`workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, the required canonical `sessionGenerationId` and `sessionGenerationSequence`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, per-segment `operationId`, and `direction`; release contexts contain the reference. Deleted-session recovery retains the same generation from the durable tombstone, so providers can reject callbacks from an older same-ID generation.
|
|
882
884
|
|
|
@@ -923,7 +925,7 @@ Resolved model identity contains provider and model IDs plus optional display na
|
|
|
923
925
|
|
|
924
926
|
Public audit history is safe to send to controllers: attachment parts contain source and size metadata, never inline base64, data URLs, or remote URL payloads. Canonical private Agent events retain those values solely for subsequent model turns.
|
|
925
927
|
|
|
926
|
-
Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
|
|
928
|
+
Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. A run stopped by `abort()`, `cancelPrompt()` or a rejected permission leaves its session `cancelled`; only a run that failed leaves it `failed`. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
|
|
927
929
|
|
|
928
930
|
## Conversation Import
|
|
929
931
|
|
|
@@ -1023,6 +1025,8 @@ Pending requests are runtime-only and queryable with `listPendingPermissions()`.
|
|
|
1023
1025
|
|
|
1024
1026
|
`always` is invalid when the request has no `rememberKey`. Remembered decisions are persisted before the waiting tool resolves. If persistence fails, the key is rolled back and the request remains pending so the response can be retried. Concurrent response attempts are serialized and exactly one successful response settles a request.
|
|
1025
1027
|
|
|
1028
|
+
`reject` stops the run as a decision, not as a failure. The waiting tool call fails with `FlexHarnessPermissionRejectedError`, a `FlexHarnessAbortError` with code `FLEX_PERMISSION_REJECTED` and the rejected `permissionId`, and the run ends the way `abort()` or `cancelPrompt()` ends it; the model is not asked to recover. The run's completion rejects with that error. `run.finished` and the finished queue entry have status `cancelled` and an `error` whose `permissionId` names the rejected request. The run's terminal user and assistant messages are `cancelled`; the assistant message carries `rejectedPermissionId` next to its `error`, and so does the session's `activity`; the session's status is `cancelled`, as after a stop. The refused tool part fails with the rejection as its error and carries the same `rejectedPermissionId`. Other permissions still pending in the run are rejected with it, and runs it delegated stop with "The parent run was aborted.". As with a stop, a run rejected after its model produced output stays in future context, where the refused call's result says the permission was rejected. A permission policy `deny` ends the run the same way.
|
|
1029
|
+
|
|
1026
1030
|
## Tool Output Safety
|
|
1027
1031
|
|
|
1028
1032
|
FlexHarness wraps every provided tool `execute` method before AgentSession receives it. Direct outputs and every `AsyncIterable` yield are converted into bounded JSON-safe values. Circular references, functions, symbols, bigint values, dates, URLs, and binary values receive deterministic descriptions or records. Returned error objects and unreadable getter values receive fixed descriptions without their original messages. Thrown errors and iterator failures remain failures but are converted to the safe external-error projection before AgentSession observes them.
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { FlexHarnessCallbackOverflowError } from './errors.js';
|
|
2
|
+
import { FlexHarnessCallbackOverflowError, FlexHarnessPermissionRejectedError } from './errors.js';
|
|
3
3
|
import {
|
|
4
4
|
FLEX_TOOL_CATALOG_TOOL_NAMES,
|
|
5
5
|
type IFlexReasoningMessagePart,
|
|
@@ -26,7 +26,7 @@ import type {
|
|
|
26
26
|
import type { FlexHarnessCore } from './classes.flexharness.core.js';
|
|
27
27
|
|
|
28
28
|
/** How a tool call ended, as its part records it. */
|
|
29
|
-
type TToolPartOutcome = { output: TJsonValue } | { error: string };
|
|
29
|
+
type TToolPartOutcome = { output: TJsonValue } | { error: string; rejectedPermissionId?: string };
|
|
30
30
|
|
|
31
31
|
const outcomeBytes = (outcome: TToolPartOutcome): number =>
|
|
32
32
|
'output' in outcome ? jsonBytes(outcome.output) : Buffer.byteLength(outcome.error);
|
|
@@ -328,7 +328,12 @@ export class FlexHarnessRunCallbacks<TScope> {
|
|
|
328
328
|
if (event.success) {
|
|
329
329
|
return { output: normalizeJsonValue(event.output, { ...this.core.toolOutputLimits, maxBytes: maxOutputBytes }) };
|
|
330
330
|
}
|
|
331
|
-
return {
|
|
331
|
+
return {
|
|
332
|
+
error: this.core.projectExternalError(run, trustedExecutionError ?? event.error, 'toolCallback').message,
|
|
333
|
+
...(trustedExecutionError instanceof FlexHarnessPermissionRejectedError
|
|
334
|
+
? { rejectedPermissionId: trustedExecutionError.permissionId }
|
|
335
|
+
: {}),
|
|
336
|
+
};
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
private completeToolPart(
|
|
@@ -344,6 +349,7 @@ export class FlexHarnessRunCallbacks<TScope> {
|
|
|
344
349
|
part.status = 'failed';
|
|
345
350
|
delete part.output;
|
|
346
351
|
part.error = outcome.error;
|
|
352
|
+
if (outcome.rejectedPermissionId) part.rejectedPermissionId = outcome.rejectedPermissionId;
|
|
347
353
|
}
|
|
348
354
|
const original = run.deduplicatedToolCalls.get(toolCallId);
|
|
349
355
|
if (original !== undefined) {
|
|
@@ -1125,7 +1125,8 @@ export class FlexHarnessCore<TScope> {
|
|
|
1125
1125
|
}
|
|
1126
1126
|
if (status !== 'completed') {
|
|
1127
1127
|
const message = status === 'cancelled' ? repairCancellationMessage : externalErrorFallback.message;
|
|
1128
|
-
|
|
1128
|
+
// As for a run that ends live: a failed run marks its prompt too, a cancelled one only its answer.
|
|
1129
|
+
if (status === 'failed') userMessage.error = message;
|
|
1129
1130
|
assistantMessage.error = message;
|
|
1130
1131
|
if (stage?.usage) {
|
|
1131
1132
|
assistantMessage.usage = cloneSerializable(stage.usage);
|
|
@@ -115,10 +115,12 @@ export class FlexHarnessPermissions<TScope> {
|
|
|
115
115
|
origin: 'policy',
|
|
116
116
|
});
|
|
117
117
|
if (policyDecision === 'deny') {
|
|
118
|
+
// A policy deny is the session's decision, not a fault: the run stops as cancelled.
|
|
118
119
|
const rejection = this.core.trustInternalError(
|
|
119
120
|
new FlexHarnessPermissionRejectedError(candidate.permissionId),
|
|
120
121
|
);
|
|
121
|
-
|
|
122
|
+
if (candidate.toolCallId) run.trustedToolExecutionErrors.set(candidate.toolCallId, rejection);
|
|
123
|
+
this.core.runs.cancelRun(run, rejection);
|
|
122
124
|
throw rejection;
|
|
123
125
|
}
|
|
124
126
|
return;
|
|
@@ -345,10 +347,14 @@ export class FlexHarnessPermissions<TScope> {
|
|
|
345
347
|
decision,
|
|
346
348
|
});
|
|
347
349
|
if (decision === 'reject') {
|
|
350
|
+
// A person's rejection stops the run as cancelled, like an owner's stop, not as a failure.
|
|
348
351
|
const rejection = this.core.trustInternalError(
|
|
349
352
|
new FlexHarnessPermissionRejectedError(pending.request.permissionId),
|
|
350
353
|
);
|
|
351
|
-
|
|
354
|
+
// The tool part of the refused call names the rejection rather than a generic failure.
|
|
355
|
+
const { toolCallId } = pending.request;
|
|
356
|
+
if (toolCallId) pending.run.trustedToolExecutionErrors.set(toolCallId, rejection);
|
|
357
|
+
this.core.runs.cancelRun(pending.run, rejection, context);
|
|
352
358
|
pending.reject(rejection);
|
|
353
359
|
} else {
|
|
354
360
|
pending.resolve();
|
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
IPendingPromptAdmission,
|
|
38
38
|
} from './interfaces.flexharness.js';
|
|
39
39
|
import { combineErrors, isAbortError } from './utils.flexharness.js';
|
|
40
|
+
import { sessionPreview } from './utils.sessionpreview.js';
|
|
40
41
|
import type { FlexHarnessCore } from './classes.flexharness.core.js';
|
|
41
42
|
|
|
42
43
|
const maxScheduleDebounceMs = 24 * 60 * 60 * 1000;
|
|
@@ -188,9 +189,14 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
188
189
|
if (stored.terminalPromptQueueEntries.has(queueId)) return false;
|
|
189
190
|
throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
190
191
|
}
|
|
192
|
+
// Only a prompt still waiting in the FIFO is a cancelled queued prompt. One that started is a
|
|
193
|
+
// run the owner stopped, and says so exactly as `abort()` does.
|
|
194
|
+
const reason = queued.status === 'queued'
|
|
195
|
+
? new FlexHarnessAbortError('The queued prompt was cancelled.')
|
|
196
|
+
: new FlexHarnessAbortError();
|
|
191
197
|
return this.cancelQueuedPrompt(
|
|
192
198
|
queued,
|
|
193
|
-
this.core.trustInternalError(
|
|
199
|
+
this.core.trustInternalError(reason),
|
|
194
200
|
this.core.compaction.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
|
|
195
201
|
);
|
|
196
202
|
}
|
|
@@ -634,6 +640,8 @@ export class FlexHarnessPromptQueue<TScope> {
|
|
|
634
640
|
startedAt: reservation.userMessage.createdAt,
|
|
635
641
|
};
|
|
636
642
|
run.stored.session.updatedAt = reservation.userMessage.createdAt;
|
|
643
|
+
const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
|
|
644
|
+
if (preview !== undefined) run.stored.session.preview = preview;
|
|
637
645
|
});
|
|
638
646
|
} catch (error) {
|
|
639
647
|
if (containsStoreCommitUncertainty(error)) run.state.lifecycle = 'fenced';
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
FLEX_REVERSION_REASON_CODE_MAX_BYTES,
|
|
12
12
|
FLEX_REVERSION_REFERENCE_MAX_BYTES,
|
|
13
13
|
FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
|
|
14
|
+
FLEX_SESSION_REVERSION_BARRIER_CAUSES,
|
|
14
15
|
FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
|
|
15
16
|
type IFlexAgentContextInvocation,
|
|
16
17
|
type IFlexAffectedWorkspace,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
type IFlexSessionTombstone,
|
|
25
26
|
type IFlexUndoSessionResult,
|
|
26
27
|
type TFlexProjectionSnapshot,
|
|
28
|
+
type TFlexSessionReversionBarrierCause,
|
|
27
29
|
type TFlexTurnReversionFinalizedOutcome,
|
|
28
30
|
type TJsonValue,
|
|
29
31
|
} from './interfaces.js';
|
|
@@ -81,6 +83,8 @@ export class FlexHarnessReversions<TScope> {
|
|
|
81
83
|
visibility: index < stored.revertCursor ? 'visible' : 'hidden',
|
|
82
84
|
affectedWorkspaces: cloneSerializable(group.affectedWorkspaces),
|
|
83
85
|
affectedWorkspacesTruncated: group.affectedWorkspacesTruncated,
|
|
86
|
+
barrierCauses: [...group.barrierCauses],
|
|
87
|
+
reasonCodes: [...group.reasonCodes],
|
|
84
88
|
})),
|
|
85
89
|
});
|
|
86
90
|
}
|
|
@@ -143,6 +147,8 @@ export class FlexHarnessReversions<TScope> {
|
|
|
143
147
|
kind: 'candidate',
|
|
144
148
|
affectedWorkspaces: [],
|
|
145
149
|
affectedWorkspacesTruncated: false,
|
|
150
|
+
barrierCauses: [],
|
|
151
|
+
reasonCodes: [],
|
|
146
152
|
});
|
|
147
153
|
} else if (groups.length > 0) {
|
|
148
154
|
groups[groups.length - 1].segments.push(segment);
|
|
@@ -162,13 +168,23 @@ export class FlexHarnessReversions<TScope> {
|
|
|
162
168
|
}
|
|
163
169
|
}
|
|
164
170
|
group.affectedWorkspaces = [...affected.values()];
|
|
171
|
+
group.reasonCodes = [...new Set(group.segments.flatMap((segment) => (
|
|
172
|
+
segment.disposition === 'nonrevertible' && segment.reasonCode !== undefined
|
|
173
|
+
? [segment.reasonCode]
|
|
174
|
+
: []
|
|
175
|
+
)))];
|
|
165
176
|
if (this.core.reversionPolicy === 'transcript-optional') continue;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|| segment.disposition === 'pending'
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
const causes = new Set<TFlexSessionReversionBarrierCause>();
|
|
178
|
+
for (const segment of group.segments) {
|
|
179
|
+
if (segment.status === 'capturing' || segment.disposition === 'pending') causes.add('pending');
|
|
180
|
+
if (segment.disposition === 'nonrevertible') causes.add('nonrevertible');
|
|
181
|
+
if (segment.protocolVersion === 1 && segment.provenance === 'transcript') {
|
|
182
|
+
causes.add('transcript-only');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (causes.size > 0) {
|
|
171
186
|
group.kind = 'barrier';
|
|
187
|
+
group.barrierCauses = FLEX_SESSION_REVERSION_BARRIER_CAUSES.filter((cause) => causes.has(cause));
|
|
172
188
|
} else if (group.segments.some((segment) => segment.disposition === 'revertible')) {
|
|
173
189
|
group.kind = 'candidate';
|
|
174
190
|
} else {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
FlexHarnessAbortError,
|
|
4
4
|
FlexHarnessExternalError,
|
|
5
5
|
FlexHarnessNotFoundError,
|
|
6
|
+
FlexHarnessPermissionRejectedError,
|
|
6
7
|
FlexHarnessRunError,
|
|
7
8
|
FlexHarnessStepLimitError,
|
|
8
9
|
FlexHarnessValidationError,
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
externalErrorFallback,
|
|
54
55
|
requireSessionGeneration,
|
|
55
56
|
combineErrors,
|
|
57
|
+
delegatedCancellation,
|
|
56
58
|
terminalActivityError,
|
|
57
59
|
} from './utils.flexharness.js';
|
|
58
60
|
import type { FlexHarnessCore } from './classes.flexharness.core.js';
|
|
@@ -664,9 +666,8 @@ export class FlexHarnessRuns<TScope> {
|
|
|
664
666
|
if (!active || active.runId !== runId || active.phase === 'finalizing' || active.phase === 'promoting') {
|
|
665
667
|
return false;
|
|
666
668
|
}
|
|
667
|
-
const cancellation = reason
|
|
668
|
-
|
|
669
|
-
: this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
|
|
669
|
+
const cancellation = delegatedCancellation(reason)
|
|
670
|
+
?? this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
|
|
670
671
|
this.cancelRun(active, cancellation);
|
|
671
672
|
return true;
|
|
672
673
|
}
|
|
@@ -914,9 +915,15 @@ export class FlexHarnessRuns<TScope> {
|
|
|
914
915
|
assistantMessage.usage = cloneSerializable(run.usage);
|
|
915
916
|
const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
|
|
916
917
|
const limit = error instanceof FlexHarnessExternalError ? error.limit : undefined;
|
|
917
|
-
|
|
918
|
+
const rejectedPermissionId = error instanceof FlexHarnessPermissionRejectedError
|
|
919
|
+
? error.permissionId
|
|
920
|
+
: undefined;
|
|
921
|
+
// A failed turn fails its prompt too. A stopped turn is no fault of its prompt, so the prompt
|
|
922
|
+
// stays an ordinary user message and only the answer it stopped carries the stop.
|
|
923
|
+
for (const failedMessage of status === 'cancelled' ? [assistantMessage] : [userMessage, assistantMessage]) {
|
|
918
924
|
failedMessage.error = message;
|
|
919
925
|
if (limit) failedMessage.limit = cloneSerializable(limit);
|
|
926
|
+
if (rejectedPermissionId) failedMessage.rejectedPermissionId = rejectedPermissionId;
|
|
920
927
|
}
|
|
921
928
|
}
|
|
922
929
|
return {
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
requireSessionGeneration,
|
|
42
42
|
sha256Hex,
|
|
43
43
|
combineErrors,
|
|
44
|
+
delegatedCancellation,
|
|
44
45
|
} from './utils.flexharness.js';
|
|
45
46
|
import type { FlexHarnessCore } from './classes.flexharness.core.js';
|
|
46
47
|
|
|
@@ -379,9 +380,8 @@ export class FlexHarnessSubagents<TScope> {
|
|
|
379
380
|
let queued: Awaited<ReturnType<typeof this.core.prompts.enqueuePromptInternal>> | undefined;
|
|
380
381
|
let admission: IFlexPromptAdmission | undefined;
|
|
381
382
|
const abortChild = () => {
|
|
382
|
-
const reason = run.controller.signal.reason
|
|
383
|
-
|
|
384
|
-
: this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
|
|
383
|
+
const reason = delegatedCancellation(run.controller.signal.reason)
|
|
384
|
+
?? this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
|
|
385
385
|
const ownedChild = child ?? run.state.sessions.get(reservedSessionId);
|
|
386
386
|
if (
|
|
387
387
|
ownedChild
|
package/ts/errors.ts
CHANGED
|
@@ -112,8 +112,9 @@ export class FlexHarnessQueueFullError extends FlexHarnessError {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export class FlexHarnessAbortError extends FlexHarnessError {
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
/** `code` names a more specific stop for a subclass, such as a rejected permission. */
|
|
116
|
+
constructor(message = 'The run was aborted.', options?: ErrorOptions, code = 'FLEX_ABORTED') {
|
|
117
|
+
super(code, message, options);
|
|
117
118
|
this.name = 'FlexHarnessAbortError';
|
|
118
119
|
}
|
|
119
120
|
}
|
|
@@ -143,10 +144,17 @@ export class FlexHarnessExternalError extends FlexHarnessError {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
/**
|
|
148
|
+
* A permission was rejected, by a `reject` response or by the session's permission policy. The
|
|
149
|
+
* rejection is a decision, not a fault: it stops the run like an owner's cancellation.
|
|
150
|
+
*/
|
|
151
|
+
export class FlexHarnessPermissionRejectedError extends FlexHarnessAbortError {
|
|
152
|
+
public readonly permissionId: string;
|
|
153
|
+
|
|
147
154
|
constructor(permissionId: string, options?: ErrorOptions) {
|
|
148
|
-
super(
|
|
155
|
+
super(`Permission "${permissionId}" was rejected.`, options, 'FLEX_PERMISSION_REJECTED');
|
|
149
156
|
this.name = 'FlexHarnessPermissionRejectedError';
|
|
157
|
+
this.permissionId = permissionId;
|
|
150
158
|
}
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -247,6 +255,7 @@ export function errorToInfo(error: unknown): IFlexErrorInfo {
|
|
|
247
255
|
message: error.message,
|
|
248
256
|
code: error.code,
|
|
249
257
|
...(error instanceof FlexHarnessExternalError && error.limit ? { limit: error.limit } : {}),
|
|
258
|
+
...(error instanceof FlexHarnessPermissionRejectedError ? { permissionId: error.permissionId } : {}),
|
|
250
259
|
};
|
|
251
260
|
}
|
|
252
261
|
if (error instanceof Error) {
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
TFlexPromptQueueStatus,
|
|
36
36
|
TFlexPendingReversion,
|
|
37
37
|
TFlexProjectionSnapshot,
|
|
38
|
+
TFlexSessionReversionBarrierCause,
|
|
38
39
|
TFlexSessionReversionGroupKind,
|
|
39
40
|
TFlexTurnReversionFinalizedOutcome,
|
|
40
41
|
} from './interfaces.js';
|
|
@@ -114,6 +115,8 @@ export interface IReversionGroup {
|
|
|
114
115
|
kind: TFlexSessionReversionGroupKind;
|
|
115
116
|
affectedWorkspaces: IFlexAffectedWorkspace[];
|
|
116
117
|
affectedWorkspacesTruncated: boolean;
|
|
118
|
+
barrierCauses: TFlexSessionReversionBarrierCause[];
|
|
119
|
+
reasonCodes: string[];
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
export type TNormalizedCaptureInspection =
|
package/ts/interfaces.ts
CHANGED
|
@@ -174,6 +174,8 @@ export interface IFlexToolMessagePart {
|
|
|
174
174
|
* did not run; its output is the earlier call's.
|
|
175
175
|
*/
|
|
176
176
|
deduplicatedFrom?: string;
|
|
177
|
+
/** For a failed call whose permission was rejected: the rejected request's `permissionId`. */
|
|
178
|
+
rejectedPermissionId?: string;
|
|
177
179
|
model?: IFlexModelIdentity;
|
|
178
180
|
/** ISO time the tool call began. Absent on parts recorded before part times existed. */
|
|
179
181
|
startedAt?: string;
|
|
@@ -216,6 +218,8 @@ export interface IFlexMessage {
|
|
|
216
218
|
error?: string;
|
|
217
219
|
/** The model limit that failed this run, next to its error. */
|
|
218
220
|
limit?: TFlexModelLimit;
|
|
221
|
+
/** The permission whose rejection stopped this run, next to its error. */
|
|
222
|
+
rejectedPermissionId?: string;
|
|
219
223
|
/** On a user message that steered its running run: the caller's steer identity. */
|
|
220
224
|
steerId?: string;
|
|
221
225
|
}
|
|
@@ -228,6 +232,8 @@ export interface IFlexSessionActivity {
|
|
|
228
232
|
error?: string;
|
|
229
233
|
/** The model limit that failed the last run, next to its error. */
|
|
230
234
|
limit?: TFlexModelLimit;
|
|
235
|
+
/** The permission whose rejection stopped the last run, next to its error. */
|
|
236
|
+
rejectedPermissionId?: string;
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
export interface IFlexSession {
|
|
@@ -238,6 +244,13 @@ export interface IFlexSession {
|
|
|
238
244
|
/** Opaque immutable configuration revision bound to this exact session generation. */
|
|
239
245
|
configurationRef?: string;
|
|
240
246
|
title?: string;
|
|
247
|
+
/**
|
|
248
|
+
* The session's first prompt as one line, bounded like a title (`maxTitleBytes`), so a session
|
|
249
|
+
* nobody titled can still be named. The harness sets it when the first run with prompt text is
|
|
250
|
+
* admitted, and for a session stored before previews existed at its next run; it never changes
|
|
251
|
+
* afterwards and is never set by a caller. Absent until then.
|
|
252
|
+
*/
|
|
253
|
+
preview?: string;
|
|
241
254
|
createdAt: string;
|
|
242
255
|
updatedAt: string;
|
|
243
256
|
archivedAt?: string;
|
|
@@ -1696,12 +1709,30 @@ export type TFlexReversionPolicy = 'transcript-optional' | 'workspace-required';
|
|
|
1696
1709
|
|
|
1697
1710
|
export type TFlexSessionReversionGroupKind = 'candidate' | 'barrier' | 'no-change';
|
|
1698
1711
|
|
|
1712
|
+
/**
|
|
1713
|
+
* Why a group is a barrier: `pending` while a capture runs or its outcome is not final,
|
|
1714
|
+
* `nonrevertible` for a workspace outcome that cannot be reverted, `transcript-only` for legacy
|
|
1715
|
+
* transcript history without a workspace capture.
|
|
1716
|
+
*/
|
|
1717
|
+
export type TFlexSessionReversionBarrierCause = 'pending' | 'nonrevertible' | 'transcript-only';
|
|
1718
|
+
|
|
1719
|
+
/** The barrier causes in the order a group lists them. */
|
|
1720
|
+
export const FLEX_SESSION_REVERSION_BARRIER_CAUSES: readonly TFlexSessionReversionBarrierCause[] = [
|
|
1721
|
+
'pending',
|
|
1722
|
+
'nonrevertible',
|
|
1723
|
+
'transcript-only',
|
|
1724
|
+
];
|
|
1725
|
+
|
|
1699
1726
|
export interface IFlexSessionReversionGroup {
|
|
1700
1727
|
readonly runId: string;
|
|
1701
1728
|
readonly kind: TFlexSessionReversionGroupKind;
|
|
1702
1729
|
readonly visibility: 'visible' | 'hidden';
|
|
1703
1730
|
readonly affectedWorkspaces: readonly IFlexAffectedWorkspace[];
|
|
1704
1731
|
readonly affectedWorkspacesTruncated: boolean;
|
|
1732
|
+
/** Each cause of a barrier once, in the order of the type; empty for every other kind. */
|
|
1733
|
+
readonly barrierCauses: readonly TFlexSessionReversionBarrierCause[];
|
|
1734
|
+
/** The distinct reason codes of the group's nonrevertible segments, in segment order. */
|
|
1735
|
+
readonly reasonCodes: readonly string[];
|
|
1705
1736
|
}
|
|
1706
1737
|
|
|
1707
1738
|
export interface IFlexSessionReversionInfo {
|
|
@@ -2128,6 +2159,8 @@ export interface IFlexErrorInfo {
|
|
|
2128
2159
|
code?: string;
|
|
2129
2160
|
/** Set when a model usage or rate limit caused the error. */
|
|
2130
2161
|
limit?: TFlexModelLimit;
|
|
2162
|
+
/** Set when a rejected permission stopped the run: the rejected request's `permissionId`. */
|
|
2163
|
+
permissionId?: string;
|
|
2131
2164
|
}
|
|
2132
2165
|
|
|
2133
2166
|
export type TFlexHarnessEvent =
|
package/ts/readme.md
CHANGED
|
@@ -693,7 +693,7 @@ await harness.dispose();
|
|
|
693
693
|
|
|
694
694
|
Only one run may be active in a session. Additional prompts enter a bounded FIFO owned by FlexHarness, while different sessions can run concurrently. `enqueuePrompt()` resolves with `{ queueId, completion }` after the immutable prompt and options have been accepted into that runtime queue and `prompt.queued` has been emitted. `startPrompt()` keeps its durable-admission behavior: it waits for its FIFO turn and resolves with `{ queueId, runId, completion }` only after the canonical generation claim, run ID, and initial public audit messages have been durably reserved and the corresponding start events have been emitted. `prompt()` preserves the simpler behavior by awaiting completion internally.
|
|
695
695
|
|
|
696
|
-
Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
|
|
696
|
+
Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path and is stopped with the same `FlexHarnessAbortError` ("The run was aborted.") as `abort()`; only a waiting entry is rejected with "The queued prompt was cancelled.". A cancelled run, whether stopped or found interrupted on reload, puts its `error` on the assistant message only; its user message is `cancelled` without an `error`, while a failed run marks both messages. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
|
|
697
697
|
|
|
698
698
|
The displayed queue limits are the defaults. Outstanding count and byte limits apply per session and include every non-terminal queued or active prompt until it settles. Pending-admission limits apply to the complete harness while scope aliases are unresolved. Terminal retention applies per session. Exceeding an admission limit throws `FlexHarnessQueueFullError`.
|
|
699
699
|
|
|
@@ -705,7 +705,7 @@ The reservation save is the admission point. A save failure produces no start ev
|
|
|
705
705
|
|
|
706
706
|
`listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. Each page carries `startIndex`, the zero-based index of its first message in the session's visible history (for an empty page, the index the page ends at), so a consumer can order and page by absolute position. Appending messages leaves every index unchanged; a branch after an undo reuses the positions of the history it replaces, so a consumer re-reads its pages after `session.history.changed`. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
|
|
707
707
|
|
|
708
|
-
`updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
|
|
708
|
+
`updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Every session exposes `preview` once a run with prompt text was admitted: its first prompt as one line (whitespace runs collapsed, cut at a character boundary to `maxTitleBytes`), with no model call, so a host can name a session nobody titled (`title || preview`). It is set once, a session stored before previews existed takes it from its first prompt at its next run, and no caller sets or clears it. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
|
|
709
709
|
|
|
710
710
|
`deleteSessionGenerationCohort()` is the generation-fenced destructive form for controllers. Its `root` and every `authorizedCohort` entry use the exact `{ sessionId, sessionGenerationId, sessionGenerationSequence }` shape. The cohort contains unique entries in strictly ascending `sessionId` order and is limited by `FLEX_SESSION_GENERATION_COHORT_MAX_ENTRIES` (2048). FlexHarness atomically validates the matching root, its complete live subtree, and every retained descendant tombstone group before reserving deletion. A missing or different root generation returns `{ matched: false }` without touching a newer generation; a missing or mismatched cascade entry throws before new tombstoning or cleanup. Extra valid entries do not expand the cascade. A matched partial cleanup remains retryable with the same authority and resolves to `{ matched: true }` when cleanup completes. The existing `deleteSession()` method remains available for callers that intentionally own the complete reusable-ID namespace.
|
|
711
711
|
|
|
@@ -872,11 +872,13 @@ for (const group of info.groups) {
|
|
|
872
872
|
group.visibility,
|
|
873
873
|
group.affectedWorkspaces,
|
|
874
874
|
group.affectedWorkspacesTruncated,
|
|
875
|
+
group.barrierCauses,
|
|
876
|
+
group.reasonCodes,
|
|
875
877
|
);
|
|
876
878
|
}
|
|
877
879
|
```
|
|
878
880
|
|
|
879
|
-
The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. It never includes capture IDs or provider references.
|
|
881
|
+
The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. `barrierCauses` states why a barrier blocks traversal, each cause once in this order: `pending` while a capture runs or its outcome is not final, `nonrevertible` for a workspace outcome that cannot be reverted, `transcript-only` for legacy transcript history without a workspace capture; it is empty for candidate and no-change groups (`FLEX_SESSION_REVERSION_BARRIER_CAUSES` lists the order). `reasonCodes` lists the distinct `reasonCode`s of the group's nonrevertible segments in segment order, so an application can say why a turn is not revertible even when its provider named no affected workspace. It never includes capture IDs or provider references.
|
|
880
882
|
|
|
881
883
|
`workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, the required canonical `sessionGenerationId` and `sessionGenerationSequence`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, per-segment `operationId`, and `direction`; release contexts contain the reference. Deleted-session recovery retains the same generation from the durable tombstone, so providers can reject callbacks from an older same-ID generation.
|
|
882
884
|
|
|
@@ -923,7 +925,7 @@ Resolved model identity contains provider and model IDs plus optional display na
|
|
|
923
925
|
|
|
924
926
|
Public audit history is safe to send to controllers: attachment parts contain source and size metadata, never inline base64, data URLs, or remote URL payloads. Canonical private Agent events retain those values solely for subsequent model turns.
|
|
925
927
|
|
|
926
|
-
Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
|
|
928
|
+
Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. A run stopped by `abort()`, `cancelPrompt()` or a rejected permission leaves its session `cancelled`; only a run that failed leaves it `failed`. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
|
|
927
929
|
|
|
928
930
|
## Conversation Import
|
|
929
931
|
|
|
@@ -1023,6 +1025,8 @@ Pending requests are runtime-only and queryable with `listPendingPermissions()`.
|
|
|
1023
1025
|
|
|
1024
1026
|
`always` is invalid when the request has no `rememberKey`. Remembered decisions are persisted before the waiting tool resolves. If persistence fails, the key is rolled back and the request remains pending so the response can be retried. Concurrent response attempts are serialized and exactly one successful response settles a request.
|
|
1025
1027
|
|
|
1028
|
+
`reject` stops the run as a decision, not as a failure. The waiting tool call fails with `FlexHarnessPermissionRejectedError`, a `FlexHarnessAbortError` with code `FLEX_PERMISSION_REJECTED` and the rejected `permissionId`, and the run ends the way `abort()` or `cancelPrompt()` ends it; the model is not asked to recover. The run's completion rejects with that error. `run.finished` and the finished queue entry have status `cancelled` and an `error` whose `permissionId` names the rejected request. The run's terminal user and assistant messages are `cancelled`; the assistant message carries `rejectedPermissionId` next to its `error`, and so does the session's `activity`; the session's status is `cancelled`, as after a stop. The refused tool part fails with the rejection as its error and carries the same `rejectedPermissionId`. Other permissions still pending in the run are rejected with it, and runs it delegated stop with "The parent run was aborted.". As with a stop, a run rejected after its model produced output stays in future context, where the refused call's result says the permission was rejected. A permission policy `deny` ends the run the same way.
|
|
1029
|
+
|
|
1026
1030
|
## Tool Output Safety
|
|
1027
1031
|
|
|
1028
1032
|
FlexHarness wraps every provided tool `execute` method before AgentSession receives it. Direct outputs and every `AsyncIterable` yield are converted into bounded JSON-safe values. Circular references, functions, symbols, bigint values, dates, URLs, and binary values receive deterministic descriptions or records. Returned error objects and unreadable getter values receive fixed descriptions without their original messages. Thrown errors and iterator failures remain failures but are converted to the safe external-error projection before AgentSession observes them.
|
package/ts/utils.flexharness.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import {
|
|
3
3
|
FlexHarnessAbortError,
|
|
4
|
+
FlexHarnessPermissionRejectedError,
|
|
4
5
|
FlexHarnessRunError,
|
|
5
6
|
FlexHarnessValidationError,
|
|
6
7
|
} from './errors.js';
|
|
@@ -106,14 +107,27 @@ export function combineErrors(errors: unknown[]): unknown {
|
|
|
106
107
|
return Object.freeze(combined);
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
/** The error
|
|
110
|
-
export function terminalActivityError(
|
|
110
|
+
/** The error, model limit and rejected permission a finished run leaves on its session activity. */
|
|
111
|
+
export function terminalActivityError(
|
|
112
|
+
message: IFlexMessage,
|
|
113
|
+
): Pick<IFlexSessionActivity, 'error' | 'limit' | 'rejectedPermissionId'> {
|
|
111
114
|
return {
|
|
112
115
|
...(message.error ? { error: message.error } : {}),
|
|
113
116
|
...(message.limit ? { limit: message.limit } : {}),
|
|
117
|
+
...(message.rejectedPermissionId ? { rejectedPermissionId: message.rejectedPermissionId } : {}),
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The parent run's cancellation when the runs it delegated share it: a stop stops them alike, but a
|
|
123
|
+
* permission the parent rejected is the parent's own.
|
|
124
|
+
*/
|
|
125
|
+
export function delegatedCancellation(reason: unknown): FlexHarnessAbortError | undefined {
|
|
126
|
+
return reason instanceof FlexHarnessAbortError && !(reason instanceof FlexHarnessPermissionRejectedError)
|
|
127
|
+
? reason
|
|
128
|
+
: undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
export function isAbortError(error: unknown): boolean {
|
|
118
132
|
return error instanceof FlexHarnessAbortError
|
|
119
133
|
|| (error instanceof DOMException && error.name === 'AbortError')
|
package/ts/utils.json.ts
CHANGED
|
@@ -533,6 +533,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
|
|
|
533
533
|
'sessionGenerationSequence',
|
|
534
534
|
'configurationRef',
|
|
535
535
|
'title',
|
|
536
|
+
'preview',
|
|
536
537
|
'createdAt',
|
|
537
538
|
'updatedAt',
|
|
538
539
|
'archivedAt',
|
|
@@ -555,6 +556,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
|
|
|
555
556
|
throw new FlexHarnessStoreFormatError(`${path}.configurationRef is invalid.`);
|
|
556
557
|
}
|
|
557
558
|
requireOptionalString(session.title, `${path}.title`);
|
|
559
|
+
requireOptionalString(session.preview, `${path}.preview`);
|
|
558
560
|
requireString(session.createdAt, `${path}.createdAt`);
|
|
559
561
|
requireString(session.updatedAt, `${path}.updatedAt`);
|
|
560
562
|
requireOptionalString(session.archivedAt, `${path}.archivedAt`);
|
|
@@ -562,7 +564,11 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
|
|
|
562
564
|
throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
|
|
563
565
|
}
|
|
564
566
|
const activity = requireRecord(session.activity, `${path}.activity`);
|
|
565
|
-
requireOnlyKeys(
|
|
567
|
+
requireOnlyKeys(
|
|
568
|
+
activity,
|
|
569
|
+
['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit', 'rejectedPermissionId'],
|
|
570
|
+
`${path}.activity`,
|
|
571
|
+
);
|
|
566
572
|
if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
|
|
567
573
|
throw new FlexHarnessStoreFormatError(`${path}.activity.status is invalid.`);
|
|
568
574
|
}
|
|
@@ -571,6 +577,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
|
|
|
571
577
|
requireOptionalString(activity.completedAt, `${path}.activity.completedAt`);
|
|
572
578
|
requireOptionalString(activity.error, `${path}.activity.error`);
|
|
573
579
|
requireOptionalModelLimit(activity.limit, `${path}.activity.limit`);
|
|
580
|
+
requireOptionalString(activity.rejectedPermissionId, `${path}.activity.rejectedPermissionId`);
|
|
574
581
|
const relationship = [
|
|
575
582
|
session.parentSessionId,
|
|
576
583
|
session.parentRunId,
|
|
@@ -767,6 +774,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
|
|
|
767
774
|
'usage',
|
|
768
775
|
'error',
|
|
769
776
|
'limit',
|
|
777
|
+
'rejectedPermissionId',
|
|
770
778
|
'steerId',
|
|
771
779
|
],
|
|
772
780
|
path,
|
|
@@ -784,6 +792,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
|
|
|
784
792
|
requireOptionalString(message.completedAt, `${path}.completedAt`);
|
|
785
793
|
requireOptionalString(message.error, `${path}.error`);
|
|
786
794
|
requireOptionalModelLimit(message.limit, `${path}.limit`);
|
|
795
|
+
requireOptionalString(message.rejectedPermissionId, `${path}.rejectedPermissionId`);
|
|
787
796
|
if (message.steerId !== undefined) {
|
|
788
797
|
requireString(message.steerId, `${path}.steerId`);
|
|
789
798
|
if (message.role !== 'user') {
|
|
@@ -834,6 +843,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
|
|
|
834
843
|
'childSessionId',
|
|
835
844
|
'parentToolCallId',
|
|
836
845
|
'deduplicatedFrom',
|
|
846
|
+
'rejectedPermissionId',
|
|
837
847
|
'model',
|
|
838
848
|
'startedAt',
|
|
839
849
|
'completedAt',
|
|
@@ -852,6 +862,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
|
|
|
852
862
|
requireOptionalString(part.childSessionId, `${partPath}.childSessionId`);
|
|
853
863
|
requireOptionalString(part.parentToolCallId, `${partPath}.parentToolCallId`);
|
|
854
864
|
requireOptionalString(part.deduplicatedFrom, `${partPath}.deduplicatedFrom`);
|
|
865
|
+
requireOptionalString(part.rejectedPermissionId, `${partPath}.rejectedPermissionId`);
|
|
855
866
|
if (part.model !== undefined) validateModel(part.model, `${partPath}.model`);
|
|
856
867
|
requireOptionalString(part.startedAt, `${partPath}.startedAt`);
|
|
857
868
|
requireOptionalString(part.completedAt, `${partPath}.completedAt`);
|