@modelprofile.com/flexharness 5.3.3 → 6.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 +3 -3
- package/dist_ts/classes.flexharness.d.ts +1 -0
- package/dist_ts/classes.flexharness.js +5 -3
- package/dist_ts/interfaces.d.ts +5 -0
- package/dist_ts/plugins.d.ts +2 -2
- package/dist_ts/plugins.js +2 -2
- package/dist_ts/utils.harnessoptions.d.ts +1 -0
- package/dist_ts/utils.harnessoptions.js +7 -1
- package/package.json +15 -30
- package/readme.md +95 -19
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/classes.flexharness.ts +4 -1
- package/ts/interfaces.ts +5 -0
- package/ts/plugins.ts +2 -2
- package/ts/readme.md +942 -0
- package/ts/utils.harnessoptions.ts +14 -0
- package/.smartconfig.json +0 -34
- package/changelog.md +0 -264
- package/readme.hints.md +0 -70
package/ts/readme.md
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
FlexHarness is a modular toolbox for model inference, standalone agents, managed sessions, tools and provider capabilities. It brings SmartAI and SmartAgent into one repository while publishing independently installable packages through tspublish.
|
|
2
|
+
|
|
3
|
+
## Issue Reporting and Security
|
|
4
|
+
|
|
5
|
+
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
|
6
|
+
|
|
7
|
+
## Choose components
|
|
8
|
+
|
|
9
|
+
All package names below use the `@modelprofile.com/` scope. Components share a release version; each published package declares only its own dependencies. The private repository root is a development dependency catalog, not an install-everything package.
|
|
10
|
+
|
|
11
|
+
| Package | Responsibility |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `flexharness-models` | Model contracts, explicit `ModelRegistry`, AI SDK helpers and prompt caching |
|
|
14
|
+
| `flexharness-provider-{anthropic,openai,google,groq,mistral,xai,perplexity,ollama}` | One explicitly selected model provider per package |
|
|
15
|
+
| `flexharness-agent` | `runAgent`, `AgentSession`, canonical events, generation transactions and tool contracts |
|
|
16
|
+
| `flexharness` | Managed scopes, sessions, permissions, public projections and persistence coordination |
|
|
17
|
+
| `flexharness-tools` | Tool factories using a host-supplied execution context, HTTP and JSON tools |
|
|
18
|
+
| `flexharness-tools-node` | Local filesystem/process contexts, `filesystemTool`, `shellTool` and file-backed job stores |
|
|
19
|
+
| `flexharness-compaction` | Model-driven conversation compaction |
|
|
20
|
+
| `flexharness-mcp` | MCP clients and AI SDK tool conversion |
|
|
21
|
+
| `flexharness-openai-auth` | ChatGPT device/browser authentication, refresh and model connections |
|
|
22
|
+
| `flexharness-openai-account` | Account lifecycle, model catalog, rate limits and credential envelopes |
|
|
23
|
+
| `flexharness-openai-auth-files` | Explicit interoperability with existing external credential files |
|
|
24
|
+
| `flexharness-media` | Vision recipes for an injected model |
|
|
25
|
+
| `flexharness-media-openai` | OpenAI audio and image capabilities |
|
|
26
|
+
| `flexharness-document` | PDF processing; this component alone brings SmartPDF |
|
|
27
|
+
| `flexharness-ocr` | OCR contracts with an injected transport; no runtime dependencies |
|
|
28
|
+
| `flexharness-research` | Anthropic research capabilities |
|
|
29
|
+
|
|
30
|
+
For model inference without an agent:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pnpm add @modelprofile.com/flexharness-models @modelprofile.com/flexharness-provider-openai
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { ModelRegistry, generateText } from '@modelprofile.com/flexharness-models';
|
|
38
|
+
import { createOpenAiModelProvider } from '@modelprofile.com/flexharness-provider-openai';
|
|
39
|
+
|
|
40
|
+
const models = new ModelRegistry().register(createOpenAiModelProvider());
|
|
41
|
+
const setup = models.getModelSetup({
|
|
42
|
+
provider: 'openai',
|
|
43
|
+
model: 'gpt-5.5',
|
|
44
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
45
|
+
});
|
|
46
|
+
const result = await generateText({ ...setup, prompt: 'Hello' });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For a standalone agent, add `@modelprofile.com/flexharness-agent` and pass the same model setup to `runAgent({ ...setup, prompt: 'Hello' })`. Providers are registered on caller-owned registries; duplicate IDs and unknown providers fail explicitly. Importing a provider, agent or harness does not register anything globally.
|
|
50
|
+
|
|
51
|
+
For OpenAI ChatGPT authentication, add `flexharness-openai-auth` and set `connection: createOpenAiChatGptModelConnection(credentials)` in the OpenAI model options. This connection supplies the existing authentication settings and system-instruction middleware. API-key inference does not depend on the authentication or account packages.
|
|
52
|
+
|
|
53
|
+
## Install the managed harness
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pnpm add @modelprofile.com/flexharness
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Node.js 24 or newer is required.
|
|
60
|
+
|
|
61
|
+
## Overview
|
|
62
|
+
|
|
63
|
+
FlexHarness owns scope isolation, public session and message projections, permission decisions, event delivery, cancellation, and persistence coordination. Each session is backed by the `flexharness-agent` component's `AgentSession`, which owns the canonical private conversation and runtime event history. Model selection, tool execution, and optional execution-context creation remain application-defined extension points. The managed harness depends on the standalone agent; it does not install provider SDKs, MCP, document processing or local execution tools.
|
|
64
|
+
|
|
65
|
+
## Migrating from SmartAI and SmartAgent
|
|
66
|
+
|
|
67
|
+
Update all related imports together and install the components they reference. The old packages are superseded; their published versions remain available so existing installations can be migrated deliberately.
|
|
68
|
+
|
|
69
|
+
| Previous import/API | Replacement |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `@push.rocks/smartai` model/cache/AI SDK contracts | `@modelprofile.com/flexharness-models` |
|
|
72
|
+
| Global `getModel` / `getModelSetup` | A `ModelRegistry` with explicit provider registrations |
|
|
73
|
+
| Provider-specific request types | The relevant `flexharness-provider-*` package |
|
|
74
|
+
| `openAiChatGptAuth` model option | `connection: createOpenAiChatGptModelConnection(credentials)` from `flexharness-openai-auth` |
|
|
75
|
+
| SmartAI root authentication functions/types | `flexharness-openai-auth` |
|
|
76
|
+
| `@push.rocks/smartai/providers` | `flexharness-openai-account` |
|
|
77
|
+
| `@push.rocks/smartai/openai-chatgpt-auth` | `flexharness-openai-auth-files` |
|
|
78
|
+
| SmartAI `/vision`, `/audio`, `/image` | `flexharness-media`, `flexharness-media-openai`, `flexharness-media-openai` |
|
|
79
|
+
| SmartAI `/document`, `/ocr`, `/research` | `flexharness-document`, `flexharness-ocr`, `flexharness-research` |
|
|
80
|
+
| `@push.rocks/smartagent` agent/event/persistence/adapter contracts | `flexharness-agent` |
|
|
81
|
+
| SmartAgent `/tools` factories and output formatting | `flexharness-tools` |
|
|
82
|
+
| Local contexts, `filesystemTool`, `shellTool`, `FileToolJobStore` | `flexharness-tools-node` |
|
|
83
|
+
| SmartAgent `/compaction`, `/mcp` | `flexharness-compaction`, `flexharness-mcp` |
|
|
84
|
+
|
|
85
|
+
Existing `ISmartAi*` and `TSmartAi*` contract names remain where their semantics are unchanged. The models package accepts provider IDs as strings; applications that expose a fixed set of providers should own that narrower union. The OpenAI account registry remains `SmartAiProviderRegistry`; it manages account adapters and is distinct from inference's `ModelRegistry`.
|
|
86
|
+
|
|
87
|
+
Durable Agent event/job schemas, FlexHarness projection schemas, credential envelopes, and external credential-source names and paths are preserved. The package move does not require a data migration. Existing FlexHarness versioned migration APIs remain at `@modelprofile.com/flexharness/migration`.
|
|
88
|
+
|
|
89
|
+
## Developing and releasing the toolbox
|
|
90
|
+
|
|
91
|
+
Each `ts_*` component has a `tspublish.json` declaring its dependencies, owned folders and published exports. Cross-component source imports use package names. TypeScript mappings provide source entrypoints for tsrun and declaration entrypoints for tsbuild's compiled-folder resolution; no workspace links are required.
|
|
92
|
+
|
|
93
|
+
`pnpm build` builds the components in declared dependency order. `pnpm test` runs the deterministic model, agent and harness regressions. Live provider tests and the browser authentication smoke test are preserved in `test_integration/` and run only when explicitly selected; they are not part of release preflight.
|
|
94
|
+
|
|
95
|
+
GitZone's `release.targets.npm.packageSource: "tspublish"` prepares and packs every component once, publishes the dependency order to the configured registries, and records the exact artifacts in its release journal. A partial release resumes those stored artifacts. Third-party OpenAI Codex notices accompany the authentication and account packages.
|
|
96
|
+
|
|
97
|
+
## Core Setup
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import {
|
|
101
|
+
FlexHarness,
|
|
102
|
+
JsonFileFlexHarnessStores,
|
|
103
|
+
type IFlexResolvedModel,
|
|
104
|
+
type TFlexAgentToolSet,
|
|
105
|
+
} from '@modelprofile.com/flexharness';
|
|
106
|
+
|
|
107
|
+
interface IProjectScope {
|
|
108
|
+
projectRoot: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const stores = new JsonFileFlexHarnessStores({
|
|
112
|
+
directory: '/var/lib/my-app/model-sessions',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const harness = new FlexHarness<IProjectScope>({
|
|
116
|
+
scopeResolver: {
|
|
117
|
+
async resolveScope(scopeId) {
|
|
118
|
+
const project = await projectRegistry.get(scopeId);
|
|
119
|
+
return {
|
|
120
|
+
// Aliases that resolve to this same key share sessions and save ordering.
|
|
121
|
+
storageKey: project.accountAndProjectKey,
|
|
122
|
+
scope: { projectRoot: project.root },
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
modelResolver: {
|
|
127
|
+
async resolveModel({ scope, modelHint, signal }): Promise<IFlexResolvedModel> {
|
|
128
|
+
const configured = await modelRegistry.resolve({ scope, modelHint, signal });
|
|
129
|
+
return {
|
|
130
|
+
model: configured.model,
|
|
131
|
+
identity: {
|
|
132
|
+
provider: configured.providerId,
|
|
133
|
+
model: configured.modelId,
|
|
134
|
+
displayName: configured.label,
|
|
135
|
+
},
|
|
136
|
+
providerOptions: configured.providerOptions,
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
toolProvider: {
|
|
141
|
+
async provideTools(context) {
|
|
142
|
+
const tools: TFlexAgentToolSet = await createProjectTools({
|
|
143
|
+
root: context.scope.projectRoot,
|
|
144
|
+
signal: context.signal,
|
|
145
|
+
requestPermission: context.requestPermission,
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
tools,
|
|
149
|
+
close: async () => closeProjectTools(tools),
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
stores,
|
|
154
|
+
builtInTools: {
|
|
155
|
+
renameSession: true,
|
|
156
|
+
projectManagement: {
|
|
157
|
+
// task, goal, and scratchpad default to true when this block exists.
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
toolOutputLimits: {
|
|
161
|
+
maxDepth: 12,
|
|
162
|
+
maxBytes: 256 * 1024,
|
|
163
|
+
},
|
|
164
|
+
callbackLimits: {
|
|
165
|
+
maxEvents: 10_000,
|
|
166
|
+
maxOutputBytes: 1024 * 1024,
|
|
167
|
+
maxParts: 2_000,
|
|
168
|
+
},
|
|
169
|
+
promptQueueLimits: {
|
|
170
|
+
maxOutstandingPromptsPerSession: 16,
|
|
171
|
+
maxOutstandingBytesPerSession: 64 * 1024 * 1024,
|
|
172
|
+
maxPendingAdmissions: 64,
|
|
173
|
+
maxPendingAdmissionBytes: 128 * 1024 * 1024,
|
|
174
|
+
maxTerminalEntriesPerSession: 64,
|
|
175
|
+
},
|
|
176
|
+
reversionLimits: {
|
|
177
|
+
maxCompletedTurns: 100,
|
|
178
|
+
maxSegments: 300,
|
|
179
|
+
maxExcludedRunIds: 1000,
|
|
180
|
+
maxPendingReversionReleases: 1000,
|
|
181
|
+
},
|
|
182
|
+
subagents: [
|
|
183
|
+
{
|
|
184
|
+
name: 'researcher',
|
|
185
|
+
description: 'Research a focused question and return one final answer.',
|
|
186
|
+
modelHint: 'reasoning-model',
|
|
187
|
+
system: 'Investigate the assigned question. Return a concise evidence-based answer.',
|
|
188
|
+
maxSteps: 8,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
maxSubagentDepth: 1,
|
|
192
|
+
maxSubagentCallsPerRun: 32,
|
|
193
|
+
externalErrorProjector: (_error, context) => ({
|
|
194
|
+
name: 'ModelOperationError',
|
|
195
|
+
message: `The ${context.source} operation failed.`,
|
|
196
|
+
code: 'MODEL_OPERATION_FAILED',
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`modelRegistry`, `projectRegistry`, `createProjectTools`, and `closeProjectTools` in this example are application-owned integrations. FlexHarness passes the same run `AbortSignal` to the model resolver and tool provider. Every model-resolver, application tool-provider, and resource tool-provider context also carries the required canonical `sessionGenerationId` and `sessionGenerationSequence`, allowing host operations to authorize the exact session generation rather than a reusable session ID alone.
|
|
202
|
+
|
|
203
|
+
## Resource Tool Providers
|
|
204
|
+
|
|
205
|
+
`resourceToolProviderResolver` composes zero or more resource-owned providers with the existing application `toolProvider`. The resolver runs fresh for every prompt and returns the current resource attachment descriptors:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
resourceToolProviderResolver: {
|
|
209
|
+
async resolveResourceToolProviders({
|
|
210
|
+
scope,
|
|
211
|
+
sessionId,
|
|
212
|
+
sessionGenerationId,
|
|
213
|
+
sessionGenerationSequence,
|
|
214
|
+
runId,
|
|
215
|
+
signal,
|
|
216
|
+
}) {
|
|
217
|
+
const attachments = await resourceRegistry.listAttached({
|
|
218
|
+
scope,
|
|
219
|
+
sessionId,
|
|
220
|
+
sessionGenerationId,
|
|
221
|
+
sessionGenerationSequence,
|
|
222
|
+
runId,
|
|
223
|
+
signal,
|
|
224
|
+
});
|
|
225
|
+
return attachments.map((attachment) => ({
|
|
226
|
+
resourceId: attachment.resourceId,
|
|
227
|
+
attachmentRevision: attachment.attachmentRevision,
|
|
228
|
+
provider: createResourceToolProvider(attachment),
|
|
229
|
+
}));
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Each descriptor uses the existing `IFlexToolProvider<TScope>` contract. Its provider receives the normal run context and must return a fresh run-scoped handle. The resource resolver context carries the same required canonical session generation as the tool-provider contexts. The original `toolProvider` remains optional and its tool names remain unchanged. Resource tool names are deterministic and bounded:
|
|
235
|
+
|
|
236
|
+
1. `resourceIdentity` is the lowercase hexadecimal SHA-256 of `JSON.stringify([resourceId, attachmentRevision])`.
|
|
237
|
+
2. The namespace is `resource_` plus the first 16 digest characters.
|
|
238
|
+
3. The exposed name is `<namespace>__<stem>__<toolDigest>`. `stem` replaces characters outside `[A-Za-z0-9_-]` with `_`, keeps the first 16 characters, and falls back to `tool`; `toolDigest` is the first 12 lowercase hexadecimal characters of SHA-256 over the original tool name.
|
|
239
|
+
|
|
240
|
+
The resolver accepts at most 128 descriptors per run. `resourceId` must be non-empty and at most 512 UTF-8 bytes, `attachmentRevision` must be a non-negative safe integer, and each original resource tool name must be non-empty and at most 512 UTF-8 bytes. FlexHarness rejects duplicate `resourceId` values even across revisions, duplicate derived namespaces, and duplicate final exposed tool names before model execution. Descriptor identity and namespace validation completes before any application or resource provider is acquired.
|
|
241
|
+
|
|
242
|
+
Resource permission requests are scoped with the complete 64-character `resourceIdentity`, not the shortened tool namespace. FlexHarness rewrites `kind` to `resource.<resourceIdentity>.<providerKind>` and an optional `rememberKey` to `resource:<resourceIdentity>:<providerRememberKey>`. Harness-owned metadata contains `resourceId`, `attachmentRevision`, `resourceIdentity`, and `toolNamespace`; provider metadata is nested under `providerMetadata`, so it cannot override attachment identity.
|
|
243
|
+
|
|
244
|
+
FlexHarness owns every acquired handle. Normal close and partial-failure cleanup run in reverse acquisition order, attempt every handle, aggregate multiple failures, and retain failed cleanup for retirement or disposal retry. Cancellation uses the same path. If model resolution fails while a resource provider is still settling, a late returned handle remains tracked and disposal waits for its closure. Application and resource providers may not define a harness built-in name while that built-in is enabled for the current run. Disabled names are not reserved.
|
|
245
|
+
|
|
246
|
+
## Project Management Tools
|
|
247
|
+
|
|
248
|
+
Harness-owned project tools are opt-in and session-local:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
builtInTools: {
|
|
252
|
+
renameSession: true,
|
|
253
|
+
projectManagement: {
|
|
254
|
+
task: true,
|
|
255
|
+
goal: true,
|
|
256
|
+
scratchpad: true,
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`renameSession` enables `rename_session`. The `projectManagement` block enables the public project-management APIs and contains the model-tool flags; `task`, `goal`, and `scratchpad` each default to enabled unless explicitly set to `false`. Without that block, the public project-management APIs reject with `FlexHarnessValidationError`, while the required `stores.projectManagement` domain still participates in session cleanup. With no `builtInTools` configuration, none of these four tools is present. Constructor options are copied and frozen.
|
|
262
|
+
|
|
263
|
+
Project-management records use `FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION`, currently `1`, and form a strict live-or-tombstone union:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
interface IFlexProjectManagementSnapshot {
|
|
267
|
+
schemaVersion: 1;
|
|
268
|
+
revision: number;
|
|
269
|
+
sessionGenerationId: string;
|
|
270
|
+
sessionGenerationSequence: number;
|
|
271
|
+
goal?: string;
|
|
272
|
+
scratchpad: string;
|
|
273
|
+
tasks: Array<{
|
|
274
|
+
id: string;
|
|
275
|
+
content: string;
|
|
276
|
+
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
|
277
|
+
priority: 'high' | 'medium' | 'low';
|
|
278
|
+
createdAt: string;
|
|
279
|
+
updatedAt: string;
|
|
280
|
+
}>;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface IFlexProjectManagementTombstone {
|
|
284
|
+
schemaVersion: 1;
|
|
285
|
+
revision: number;
|
|
286
|
+
sessionGenerationId: string;
|
|
287
|
+
sessionGenerationSequence: number;
|
|
288
|
+
deletedAt: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type TFlexProjectManagementRecord =
|
|
292
|
+
| IFlexProjectManagementSnapshot
|
|
293
|
+
| IFlexProjectManagementTombstone;
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The tools use strict action-discriminated inputs:
|
|
297
|
+
|
|
298
|
+
- `task`: `list`, `create`, `update`, `delete`, or `clear`. Create defaults to `pending` and `medium`.
|
|
299
|
+
- `goal`: `get`, `set`, or `clear`.
|
|
300
|
+
- `scratchpad`: `get`, `set`, `append`, or `clear`. Append concatenates the supplied content exactly.
|
|
301
|
+
- `rename_session`: sets the active session title and returns the authoritative session.
|
|
302
|
+
|
|
303
|
+
Every project action returns the authoritative revision and state; task mutations also return the affected task, and clear returns the removed tasks. Reads never save. A set, clear, append, update, idempotent create, or empty task clear that makes no state change returns the current revision without writing. Mutations load once, apply once, validate the complete next snapshot, and issue one compare-and-swap save at `revision + 1`. FlexHarness never retries or merges an external conflict.
|
|
304
|
+
|
|
305
|
+
Tool task creation accepts an optional `id`. When omitted, FlexHarness requires the stable AgentSession `toolCallId` and derives `task_` plus the SHA-256 of `JSON.stringify(['flexharness-project-task-v1', storageKey, sessionId, runId, toolCallId])`. Repeating an explicit or deterministic ID with identical content, status, and priority is idempotent; different creation data conflicts. Application callers must supply an explicit `id` to `createProjectTask()` because no tool-call identity exists at that boundary.
|
|
306
|
+
|
|
307
|
+
The same engine is available to applications:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
await harness.getProjectState(scopeId, sessionId);
|
|
311
|
+
await harness.listProjectTasks(scopeId, sessionId);
|
|
312
|
+
await harness.createProjectTask(scopeId, sessionId, { id, content, status, priority });
|
|
313
|
+
await harness.updateProjectTask(scopeId, sessionId, { id, content, status, priority });
|
|
314
|
+
await harness.deleteProjectTask(scopeId, sessionId, id);
|
|
315
|
+
await harness.clearProjectTasks(scopeId, sessionId);
|
|
316
|
+
await harness.getProjectGoal(scopeId, sessionId);
|
|
317
|
+
await harness.setProjectGoal(scopeId, sessionId, goal);
|
|
318
|
+
await harness.clearProjectGoal(scopeId, sessionId);
|
|
319
|
+
await harness.getProjectScratchpad(scopeId, sessionId);
|
|
320
|
+
await harness.setProjectScratchpad(scopeId, sessionId, content);
|
|
321
|
+
await harness.appendProjectScratchpad(scopeId, sessionId, content);
|
|
322
|
+
await harness.clearProjectScratchpad(scopeId, sessionId);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Public writes use `{ actor: 'application' }`. Tool writes use `{ actor: 'agent', runId, toolCallId, agent? }`, allowing custom stores to preserve attribution. Project side effects commit independently of the later model outcome and are intentionally outside transcript undo/redo.
|
|
326
|
+
|
|
327
|
+
`FLEX_PROJECT_MANAGEMENT_LIMITS` exports the hard UTF-8 and aggregate limits: goal 8 KiB, scratchpad 128 KiB, task content 8 KiB, task ID 512 bytes, title 2048 bytes, 512 tasks, and a 1 MiB serialized snapshot. The aggregate bound leaves room for worst-case JSON escaping of a controller-valid scratchpad. Loaded snapshots reject extra fields, duplicate IDs, invalid status/priority/timestamps, non-JSON data, wrong schema/revision, and every exceeded bound before use.
|
|
328
|
+
|
|
329
|
+
`IFlexProjectManagementStore` is exact per `(storageKey, sessionId)`: `load`, CAS `save`, CAS `tombstoneSession`, and `purgeNamespace` must not collapse multiple sessions or storage namespaces. `load()` returns `TFlexProjectManagementRecord | undefined` and receives an optional `IFlexProjectManagementSessionContext` as its third argument; `tombstoneSession()` receives the same optional context as its fifth argument. FlexHarness always supplies both, while existing two-argument loads, four-argument tombstones, and shorter store implementations remain compatible.
|
|
330
|
+
|
|
331
|
+
`IFlexProjectManagementSessionContext` contains `sessionGenerationId`, `sessionGenerationSequence`, and optional `subagent`. The atomic `IFlexSubagentProvenance` block contains `parentSessionId`, `parentSessionGenerationId`, `parentSessionGenerationSequence`, `originParentRunId`, `originParentToolCallId`, `agent`, and actual session `depth`. Both the context and its separately cloned nested block are frozen.
|
|
332
|
+
|
|
333
|
+
Same-generation live saves use normal revision CAS, and a same-generation tombstone permanently rejects later live saves. A higher `sessionGenerationSequence` with a different `sessionGenerationId` may replace only an older tombstone using expected revision `0`; it cannot replace a live record. This resets the PM revision for a recreated core session while stale saves and tombstones from older generations remain fenced. Deleting a recreated session that made no PM writes still replaces the prior-generation tombstone with a revision-1 tombstone for the new generation.
|
|
334
|
+
|
|
335
|
+
Every newly created core session exposes and persists a `sessionGenerationId` plus its monotonic `sessionGenerationSequence`. FlexHarness generates a strong random ID when `sessionGenerationId` is omitted. Applications may supply the ID to `createSession()` when they need to persist creation authority before dispatch; a supplied ID must be nonblank, contain no control characters, and fit within `FLEX_SESSION_GENERATION_ID_MAX_BYTES` (128 UTF-8 bytes). FlexHarness still assigns the sequence atomically. A legacy scope session without those fields is assigned a deterministic bounded ID derived from its immutable `storageKey`, `sessionId`, and `createdAt`; FlexHarness persists the repaired scope snapshot before accepting work. Grouped core deletion tombstones retain both fields after live metadata is removed.
|
|
336
|
+
|
|
337
|
+
Normal Flex session cleanup always waits in-flight local project operations, then loads and CAS-tombstones `stores.projectManagement`, regardless of whether PM tools are enabled in that harness. Child cleanup persists one complete `IFlexSubagentProvenance` block on its core tombstone before live metadata is removed. One cleanup invocation reuses the identical doubly frozen context object across bounded CAS retries; restart or a later cleanup invocation reconstructs a new frozen context from the persisted provenance. If a concurrent same-generation save wins first, cleanup reloads and retries; unresolved conflict or store failure retains the core Flex session cleanup tombstone for a later retry. The durable PM tombstone is not physically removed during normal session cleanup.
|
|
338
|
+
|
|
339
|
+
`purgeNamespace(storageKey)` is the explicit destructive reclamation operation and physically removes every live record and tombstone in that exact PM namespace. Applications may call it only after serializing every scope alias, preventing new admission, awaiting `retireScope()` on every harness owner, and deleting or purging the application-owned core scope namespace. `retireScope()` itself remains non-destructive and never calls `purgeNamespace()`. Purging PM first, purging only one alias, or racing a stale harness can remove the fence that makes session-generation reuse safe.
|
|
340
|
+
|
|
341
|
+
`InMemoryFlexProjectManagementStore` is the standalone in-memory implementation. `InMemoryFlexHarnessStores` and `JsonFileFlexHarnessStores` include `projectManagement` as a required bundle member. `assertFlexProjectManagementSnapshot()` validates live records, `assertFlexProjectManagementTombstone()` validates tombstones, and `assertFlexProjectManagementRecord()` validates the union. `createEmptyFlexProjectManagementSnapshot(sessionGenerationId, sessionGenerationSequence)` returns a revision-0 live state for the supplied current generation.
|
|
342
|
+
|
|
343
|
+
## Foreground Subagents
|
|
344
|
+
|
|
345
|
+
`subagents` enables a harness-owned built-in tool named `delegate`. It is available only when at least one definition exists and the current session depth is below `maxSubagentDepth`. An application or resource `toolProvider` must not return its own `delegate` tool while the built-in is enabled for that run. The built-in is foreground-only: the parent tool call does not complete until the child prompt reaches a terminal outcome.
|
|
346
|
+
|
|
347
|
+
The model calls it with this exact input shape:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
interface IDelegateInput {
|
|
351
|
+
description: string;
|
|
352
|
+
prompt: string;
|
|
353
|
+
subagentType: string;
|
|
354
|
+
taskId?: string;
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
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`.
|
|
359
|
+
|
|
360
|
+
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()` remains limited to `sessionId`, `sessionGenerationId`, and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the foreground `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`.
|
|
361
|
+
|
|
362
|
+
The parent tool part receives `childSessionId` in a cumulative `part.updated` event as soon as the child is acquired. If child model resolution completes, a later cumulative update adds `model`; failures before model resolution leave it absent. The terminal tool part retains every value that became available. A successful delegate call always has model identity and returns bounded JSON:
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
{
|
|
366
|
+
taskId: 'subagent_...',
|
|
367
|
+
status: 'completed',
|
|
368
|
+
text: 'The child final answer, limited to 64 KiB.',
|
|
369
|
+
model: { provider: '...', model: '...', displayName: '...', variant: '...' },
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Omitting `taskId` creates a deterministic child for the parent session, run, and tool call. The model-visible tool description and `taskId` schema state this creation rule directly. Repeating that same invocation does not create another child. If the deterministic child already has messages, FlexHarness reports an uncertain prior execution and never silently reruns it. This preserves AgentSession's durable parent tool intent as crash authority; controllers use `listUncertainToolExecutions()` and `reconcileToolExecution()` for uncertain parent calls.
|
|
374
|
+
|
|
375
|
+
Supplying `taskId` deliberately resumes an idle, live child from a later run of the same immutable parent session and the same configured agent. The caller must use the exact ID returned by an earlier completed delegate call; an unknown ID fails with safe corrective guidance and never creates a child under the supplied label. Resume starts a new child prompt while retaining the child's original parent run and tool-call origin. A child owned by another parent or agent, a deleted child, an active child, a same-run resume, or a second acquisition of the same child within one later parent run is rejected. Parent cancellation propagates only to the exact child run started by that delegate call.
|
|
376
|
+
|
|
377
|
+
`delegatedRunAdmissionProvider` optionally adds an application-owned admission lease around each internally delegated child run. The public contracts are `IFlexDelegatedRunAdmissionProvider<TScope>`, `IFlexDelegatedRunAdmissionContext<TScope>`, and `IFlexDelegatedRunAdmissionLease`. The provider is never called for root prompts or direct public prompt APIs. It receives a frozen context containing the resolved `scopeId`, exact captured `scope`, and `storageKey`; the exact child `sessionId`, session generation, queue, and run; the exact current parent session generation, queue, run, and delegate `toolCallId`; immutable `originParentRunId` and `originParentToolCallId`; the child agent and depth; and the child run `AbortSignal`. For `taskId` resume, the current parent queue/run/tool-call fields identify this delegate invocation, while the origin fields remain fixed to the invocation that created the durable child.
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
delegatedRunAdmissionProvider: {
|
|
381
|
+
async acquireDelegatedRunAdmission(context) {
|
|
382
|
+
const admission = await controller.acquireDelegatedRun({
|
|
383
|
+
child: {
|
|
384
|
+
sessionId: context.sessionId,
|
|
385
|
+
sessionGenerationId: context.sessionGenerationId,
|
|
386
|
+
sessionGenerationSequence: context.sessionGenerationSequence,
|
|
387
|
+
queueId: context.queueId,
|
|
388
|
+
runId: context.runId,
|
|
389
|
+
},
|
|
390
|
+
parent: {
|
|
391
|
+
sessionId: context.parentSessionId,
|
|
392
|
+
sessionGenerationId: context.parentSessionGenerationId,
|
|
393
|
+
sessionGenerationSequence: context.parentSessionGenerationSequence,
|
|
394
|
+
queueId: context.parentQueueId,
|
|
395
|
+
runId: context.parentRunId,
|
|
396
|
+
toolCallId: context.parentToolCallId,
|
|
397
|
+
originRunId: context.originParentRunId,
|
|
398
|
+
originToolCallId: context.originParentToolCallId,
|
|
399
|
+
},
|
|
400
|
+
signal: context.signal,
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
close: () => admission.close(),
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Acquisition completes before generation-side branch reversion, context compaction, model resolution, application or resource tool-provider callbacks, and model execution. Child session store and runtime initialization may already have occurred before acquisition. Providers must honor the supplied `AbortSignal`; an abort can settle the active child and parent without waiting for an acquisition that ignores cancellation, while FlexHarness retains ownership and closes any lease returned later.
|
|
410
|
+
|
|
411
|
+
For a normally acquired lease, FlexHarness gives `close()` an awaited attempt after AgentSession generation and before canonical accepted, rejected, or interrupted finalization and the terminal `prompt.finished` event. If an abort detaches an acquisition that ignores its signal, the run may settle before acquisition returns; FlexHarness retains that owner and closes any late lease. `close()` must be idempotent and safe to retry after rejection. A close failure prevents successful child acceptance and remains owned by the exact child session generation for retry by later exact-session deletion, scope retirement, or disposal. Retirement and disposal truthfully wait for late acquisition and lease cleanup.
|
|
412
|
+
|
|
413
|
+
Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 1 and must be a positive safe integer at most 8. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID candidate is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves that unverified candidate and attempts resume after permission only if it identifies a resumable child. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
|
|
414
|
+
|
|
415
|
+
Scope snapshots remain schema 1 and legacy child tombstones without `subagent` provenance continue to load. New child tombstones write the atomic provenance block and reject partial, parent-generation-mismatched, depth-mismatched, or duplicate-origin records. FlexHarness versions before this provenance addition reject that new optional key under their strict reader, so downgrading or mixing old readers with newly written scope snapshots is unsupported.
|
|
416
|
+
|
|
417
|
+
## Sessions And Prompts
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
const session = await harness.createSession('project:billing', {
|
|
421
|
+
title: 'Invoice import',
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const result = await harness.prompt(
|
|
425
|
+
'project:billing',
|
|
426
|
+
session.sessionId,
|
|
427
|
+
[
|
|
428
|
+
{ type: 'text', text: 'Extract the invoice totals.' },
|
|
429
|
+
{
|
|
430
|
+
type: 'file',
|
|
431
|
+
data: invoicePdfBase64,
|
|
432
|
+
mediaType: 'application/pdf',
|
|
433
|
+
name: 'invoice.pdf',
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
{ modelHint: 'document-model', maxSteps: 12 },
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
console.log(result.assistantMessage.parts);
|
|
440
|
+
console.log(result.usage);
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`TFlexPrompt` is deliberately JSON-safe. It accepts a string or an ordered array of:
|
|
444
|
+
|
|
445
|
+
- `{ type: 'text', text }`
|
|
446
|
+
- `{ type: 'image', data, mediaType?, name? }`
|
|
447
|
+
- `{ type: 'file', data, mediaType, name? }`
|
|
448
|
+
|
|
449
|
+
Attachment `data` is a string containing base64, a data URL, or a remote URL. Public input never requires `Buffer` or `URL` objects. Remote URL strings are converted only at the private AgentSession invocation boundary.
|
|
450
|
+
|
|
451
|
+
Attachment payloads are never copied into public audit messages or events. Public attachment parts contain metadata only:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
{
|
|
455
|
+
type: 'attachment',
|
|
456
|
+
partId: '...',
|
|
457
|
+
attachmentType: 'file',
|
|
458
|
+
source: 'inline-base64', // or data-url / remote-url
|
|
459
|
+
sizeBytes: 48231, // omitted when it cannot be determined
|
|
460
|
+
mediaType: 'application/pdf',
|
|
461
|
+
name: 'invoice.pdf',
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
When the turn succeeds, the original string remains only in canonical private Agent events so a later model turn can receive the attachment again. Failed, cancelled, resolver-failed, cleanup-failed, and persistence-failed turns do not add it to future context.
|
|
466
|
+
|
|
467
|
+
The main session methods are:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
await harness.listSessions(scopeId);
|
|
471
|
+
await harness.createSession(scopeId, { sessionId, sessionGenerationId, title });
|
|
472
|
+
await harness.getSession(scopeId, sessionId);
|
|
473
|
+
await harness.getMessages(scopeId, sessionId);
|
|
474
|
+
await harness.listMessagePage(scopeId, sessionId, { limit: 50, before: cursor });
|
|
475
|
+
await harness.getMessage(scopeId, sessionId, messageId);
|
|
476
|
+
await harness.listSlashCommands(scopeId, sessionId);
|
|
477
|
+
const command = await harness.executeSlashCommand(scopeId, sessionId, '/init focus on tests');
|
|
478
|
+
if (command.type === 'prompt-admission') {
|
|
479
|
+
console.log(command.admission.queueId, command.admission.runId);
|
|
480
|
+
await command.admission.completion;
|
|
481
|
+
}
|
|
482
|
+
await harness.updateSession(scopeId, sessionId, { title: 'Renamed', archived: true });
|
|
483
|
+
await harness.updateSession(scopeId, sessionId, { title: null, archived: false });
|
|
484
|
+
await harness.getProjectState(scopeId, sessionId);
|
|
485
|
+
await harness.createProjectTask(scopeId, sessionId, { id: 'tests', content: 'Add tests' });
|
|
486
|
+
await harness.setProjectGoal(scopeId, sessionId, 'Ship the next release');
|
|
487
|
+
await harness.appendProjectScratchpad(scopeId, sessionId, 'One durable note.');
|
|
488
|
+
await harness.deleteSession(scopeId, sessionId);
|
|
489
|
+
await harness.deleteSessionGenerationCohort(scopeId, {
|
|
490
|
+
root: { sessionId, sessionGenerationId, sessionGenerationSequence },
|
|
491
|
+
authorizedCohort,
|
|
492
|
+
});
|
|
493
|
+
await harness.prompt(scopeId, sessionId, prompt, options);
|
|
494
|
+
const queued = await harness.enqueuePrompt(scopeId, sessionId, prompt, options);
|
|
495
|
+
console.log(queued.queueId);
|
|
496
|
+
await queued.completion;
|
|
497
|
+
const admission = await harness.startPrompt(scopeId, sessionId, prompt, options);
|
|
498
|
+
console.log(admission.queueId);
|
|
499
|
+
console.log(admission.runId);
|
|
500
|
+
await admission.completion;
|
|
501
|
+
const scheduled = await harness.schedulePrompt(
|
|
502
|
+
scopeId,
|
|
503
|
+
sessionId,
|
|
504
|
+
'refresh-index',
|
|
505
|
+
prompt,
|
|
506
|
+
{ debounceMs: 250 },
|
|
507
|
+
);
|
|
508
|
+
await harness.cancelScheduledPrompt(scopeId, sessionId, scheduled.scheduleKey);
|
|
509
|
+
await harness.getPromptQueueEntry(scopeId, sessionId, queued.queueId);
|
|
510
|
+
await harness.listPromptQueueEntries(scopeId, sessionId);
|
|
511
|
+
await harness.cancelPrompt(scopeId, sessionId, queued.queueId);
|
|
512
|
+
await harness.abort(scopeId, sessionId);
|
|
513
|
+
await harness.listPendingPermissions(scopeId, sessionId);
|
|
514
|
+
await harness.respondToPermission(scopeId, sessionId, permissionId, 'once');
|
|
515
|
+
await harness.pushRuntimeEvent(scopeId, sessionId, { type: 'workspace.changed', path: 'src/' });
|
|
516
|
+
await harness.listUncertainToolExecutions(scopeId, sessionId);
|
|
517
|
+
await harness.reconcileToolExecution(scopeId, sessionId, intentId, {
|
|
518
|
+
resolution: 'executed',
|
|
519
|
+
output: { committed: true },
|
|
520
|
+
});
|
|
521
|
+
const reversion = await harness.getSessionReversionInfo(scopeId, sessionId);
|
|
522
|
+
console.log(reversion.undoAvailable, reversion.redoAvailable, reversion.groups);
|
|
523
|
+
const undone = await harness.undoSession(scopeId, sessionId);
|
|
524
|
+
console.log(undone.revertedRunId);
|
|
525
|
+
const redone = await harness.redoSession(scopeId, sessionId);
|
|
526
|
+
console.log(redone.restoredRunId);
|
|
527
|
+
await harness.compactSession(scopeId, sessionId);
|
|
528
|
+
await harness.archiveSessionEvents(scopeId, sessionId, compactionEventId);
|
|
529
|
+
await harness.listBackgroundExecutions(scopeId, sessionId);
|
|
530
|
+
await harness.getBackgroundExecution(scopeId, sessionId, executionId);
|
|
531
|
+
await harness.abortBackgroundExecution(scopeId, sessionId, executionId);
|
|
532
|
+
await harness.retireScope(scopeId);
|
|
533
|
+
await harness.dispose();
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
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.
|
|
537
|
+
|
|
538
|
+
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.
|
|
539
|
+
|
|
540
|
+
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`.
|
|
541
|
+
|
|
542
|
+
Queue payloads, status records, and `prompt.*` queue events are process-local. The existing stores do not have a private generic queue domain: projections are deliberately redacted, Agent events are canonical conversation transactions, and jobs are AgentSession background executions. FlexHarness therefore never writes a never-started prompt into those unrelated domains. A process restart drops never-started entries; a prompt that reached durable run admission continues to use the existing canonical recovery policy and is repaired to a safe terminal state instead of being replayed.
|
|
543
|
+
|
|
544
|
+
`schedulePrompt()` waits for its FIFO turn, performs the same durable admission, exposes session status `scheduled`, and starts model preparation after its bounded `debounceMs` delay. Schedule keys remain unique across waiting and active prompts. `cancelScheduledPrompt()` returns `true` only while the matching schedule key can still be cancelled. Cancelling while it is still waiting rejects the `schedulePrompt()` call itself; cancelling after durable admission rejects the returned completion and marks its reserved audit messages cancelled.
|
|
545
|
+
|
|
546
|
+
The reservation save is the admission point. A save failure produces no start events or active audit. If disposal begins while that save is in flight and the save commits, admission still resolves and its completion settles as cancelled; disposal waits for terminal finalization.
|
|
547
|
+
|
|
548
|
+
`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. `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.
|
|
549
|
+
|
|
550
|
+
`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. 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.
|
|
551
|
+
|
|
552
|
+
`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.
|
|
553
|
+
|
|
554
|
+
`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.
|
|
555
|
+
|
|
556
|
+
## Slash Commands
|
|
557
|
+
|
|
558
|
+
`parseSlashCommand()` is the public strict pure parser. It accepts at most 768 KiB, returns `not-command` for input not starting with `/`, `malformed` for invalid slash syntax, and `parsed` with the exact input, lowercase command name, separator-stripped raw argument text, and OpenCode-compatible quoted tokenization. Command names match `[a-z][a-z0-9-]{0,63}`. Single and double quotes group tokens and are stripped; escapes are not interpreted.
|
|
559
|
+
|
|
560
|
+
Applications register immutable custom commands at construction:
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
const harness = new FlexHarness({
|
|
564
|
+
// scopeResolver, modelResolver, and other options...
|
|
565
|
+
slashCommands: [
|
|
566
|
+
{
|
|
567
|
+
name: 'review-area',
|
|
568
|
+
description: 'Review one area of the workspace.',
|
|
569
|
+
template: 'Review $1 with these additional constraints: $ARGUMENTS',
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: 'refresh-index',
|
|
573
|
+
description: 'Refresh the application-owned workspace index.',
|
|
574
|
+
async handler({
|
|
575
|
+
scopeId,
|
|
576
|
+
scope,
|
|
577
|
+
storageKey,
|
|
578
|
+
sessionId,
|
|
579
|
+
sessionGenerationId,
|
|
580
|
+
sessionGenerationSequence,
|
|
581
|
+
rawArguments,
|
|
582
|
+
arguments,
|
|
583
|
+
signal,
|
|
584
|
+
}) {
|
|
585
|
+
return indexer.refresh({
|
|
586
|
+
scopeId,
|
|
587
|
+
scope,
|
|
588
|
+
storageKey,
|
|
589
|
+
sessionId,
|
|
590
|
+
sessionGenerationId,
|
|
591
|
+
sessionGenerationSequence,
|
|
592
|
+
rawArguments,
|
|
593
|
+
arguments,
|
|
594
|
+
signal,
|
|
595
|
+
});
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
`compact`, `init`, `undo`, and `redo` are reserved. `listSlashCommands()` verifies the scope and session and returns immutable data-only descriptors with `kind`, placeholder `hints`, current immediate availability, and `workspaceReversion`. `compact`, `undo`, `redo`, and custom handlers require an otherwise idle command session. Prompt templates and `init` use the normal bounded FIFO and may wait behind an active prompt. Their descriptors report availability from the same queue-admission conditions used by execution: lifecycle, slash ownership, pending reversion, root-session eligibility, outstanding count, and estimated prompt bytes. A dynamic capacity race may still produce `FlexHarnessQueueFullError` during admission. Only one slash-command execution may own a session at a time.
|
|
603
|
+
|
|
604
|
+
At most 128 custom commands may be registered. Every registration must be a plain object with exactly one of `template` or `handler`; names must match `[a-z][a-z0-9-]{0,63}`, be unique, and not use a reserved name. Optional descriptions must be non-empty and at most 2048 UTF-8 bytes. Templates must be non-empty and at most 768 KiB, and the expanded prompt must also fit 768 KiB. Registrations are copied and frozen during construction.
|
|
605
|
+
|
|
606
|
+
`/undo` and `/redo`, plus `undoSession()` and `redoSession()`, move a durable history cursor. The direct methods return `{ revertedRunId }` and `{ restoredRunId }`; the slash forms return `{ type: 'operation', name: 'undo' | 'redo' }`. Each committed cursor move emits one `session.history.changed` event with `direction`, `runId`, and the selected session identity. A committed branch emits the same event with `direction: 'branch'` and no `runId`, so controllers should refresh the complete selected session. Capture finalization, cleanup, and metadata-only changes do not emit this event.
|
|
607
|
+
|
|
608
|
+
A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact.
|
|
609
|
+
|
|
610
|
+
Two horizons bound undo. Retention pruning removes the oldest complete visible units when `reversionLimits` is exceeded. Explicit event archival marks covered turns context-unavailable and prunes complete prefixes that can no longer be rebuilt; FlexHarness never crosses that archive horizon. Manual compaction without archival retains the original events and remains undoable. Schema-1 projection history and sessions migrated from `2.x` have no reversion segments, so historical turns are not retroactively undoable; newly written turns are tracked normally.
|
|
611
|
+
|
|
612
|
+
Session metadata archival through `updateSession(..., { archived: true })` only sets `archivedAt`. It does not archive Agent events, retire captures, or remove undo history.
|
|
613
|
+
|
|
614
|
+
`executeSlashCommand()` is the authoritative parser and lookup boundary. Its result distinguishes `not-command`, `malformed`, `unknown`, completed `operation`, bounded `handler-result`, and `prompt-admission`. A prompt admission contains the normal `{ queueId, runId, completion }`; await `admission.completion` for the model result. Unknown commands are never admitted as literal prompts. Known unavailable commands and invalid arguments throw typed FlexHarness errors. Options accept `modelHint`, `system`, `maxSteps`, and `signal`; commands do not accept attachments. Aborting a template or `init` execution cancels its exact queued or started prompt without affecting another queue entry.
|
|
615
|
+
|
|
616
|
+
Templates replace every `$ARGUMENTS` with untouched raw argument text. `$1` through the highest referenced positional placeholder use tokenized arguments, with the highest position receiving all remaining tokens joined by spaces. Missing positions become empty. A template with no placeholders appends non-empty raw arguments after a blank line. `/init` uses the OpenCode 1.18.15 `AGENTS.md` initialization prompt with provider-neutral active-workspace wording.
|
|
617
|
+
|
|
618
|
+
Handler context is frozen and contains only the resolved scope identity, session identity, required canonical `sessionGenerationId` and `sessionGenerationSequence`, raw and tokenized arguments, and an `AbortSignal`. Handler results are converted with the configured `toolOutputLimits`; `void` becomes JSON `null`. Handler failures use `externalErrorProjector` with source `slashCommand`. Same-session command overlap is rejected, including reentry from a handler. Scope retirement and disposal abort and await active handlers; prompt-admission commands transfer immediately to the normal prompt lifecycle.
|
|
619
|
+
|
|
620
|
+
### Workspace Reversion Provider
|
|
621
|
+
|
|
622
|
+
`reversionPolicy` defaults to `transcript-optional`, preserving the V1 behavior described above. Set it to `workspace-required` when transcript and workspace traversal must move together. This policy requires an `IFlexTurnReversionProviderV2` at construction.
|
|
623
|
+
|
|
624
|
+
Applications using the original protocol can continue to provide all six unchanged `IFlexTurnReversionProvider` operations:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
import type { IFlexTurnReversionProvider } from '@modelprofile.com/flexharness';
|
|
628
|
+
|
|
629
|
+
const turnReversionProvider: IFlexTurnReversionProvider<IProjectScope> = {
|
|
630
|
+
prepare: (context) => workspaceSnapshots.prepare(context),
|
|
631
|
+
inspectCapture: (context) => workspaceSnapshots.inspectCapture(context),
|
|
632
|
+
finalize: (context) => workspaceSnapshots.finalize(context),
|
|
633
|
+
inspectApply: (context) => workspaceSnapshots.inspectApply(context),
|
|
634
|
+
apply: (context) => workspaceSnapshots.apply(context),
|
|
635
|
+
release: (context) => workspaceSnapshots.release(context),
|
|
636
|
+
};
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
Protocol 2 adds the `protocolVersion` discriminant and a tagged finalized outcome. The prepare, apply, apply-inspection, and release contexts remain the V1 shapes:
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
import type {
|
|
643
|
+
IFlexTurnReversionProviderV2,
|
|
644
|
+
} from '@modelprofile.com/flexharness';
|
|
645
|
+
|
|
646
|
+
const turnReversionProvider: IFlexTurnReversionProviderV2<IProjectScope> = {
|
|
647
|
+
protocolVersion: 2,
|
|
648
|
+
prepare: (context) => workspaceHistory.prepare(context),
|
|
649
|
+
inspectCapture: (context) => workspaceHistory.inspectCapture(context),
|
|
650
|
+
async finalize(context) {
|
|
651
|
+
const capture = await workspaceHistory.finalize(context);
|
|
652
|
+
if (capture.changedPaths.length === 0) {
|
|
653
|
+
return {
|
|
654
|
+
disposition: 'no-change',
|
|
655
|
+
reference: capture.cleanupReference,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (!capture.revertible) {
|
|
659
|
+
return {
|
|
660
|
+
disposition: 'nonrevertible',
|
|
661
|
+
reference: capture.cleanupReference,
|
|
662
|
+
reasonCode: 'git.unmerged',
|
|
663
|
+
affectedWorkspaces: [{ id: capture.workspaceId, label: capture.workspaceLabel }],
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
disposition: 'revertible',
|
|
668
|
+
reference: capture.reference,
|
|
669
|
+
affectedWorkspaces: [{ id: capture.workspaceId, label: capture.workspaceLabel }],
|
|
670
|
+
};
|
|
671
|
+
},
|
|
672
|
+
inspectApply: (context) => workspaceHistory.inspectApply(context),
|
|
673
|
+
apply: (context) => workspaceHistory.apply(context),
|
|
674
|
+
release: (context) => workspaceHistory.release(context),
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const harness = new FlexHarness<IProjectScope>({
|
|
678
|
+
// scopeResolver, modelResolver, stores, and other options...
|
|
679
|
+
turnReversionProvider,
|
|
680
|
+
reversionPolicy: 'workspace-required',
|
|
681
|
+
});
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
V1 `finalize()` returns a JSON reference, and a finalized V1 `inspectCapture()` result is `{ status: 'finalized', reference }`. V2 `finalize()` returns `revertible`, `no-change`, or `nonrevertible`, and a finalized V2 inspection is `{ status: 'finalized', outcome }` with the same complete tagged outcome. Every V2 outcome carries a normalized cleanup reference. A revertible outcome also carries `affectedWorkspaces`. A no-change outcome omits that list or supplies an empty list. A nonrevertible outcome carries a stable `reasonCode` and may carry affected workspaces.
|
|
685
|
+
|
|
686
|
+
Affected workspace descriptors contain only stable, non-whitespace `id` and display `label` strings. A result accepts at most 64 unique descriptors; IDs are limited to 512 UTF-8 bytes, labels to 2048 bytes, and reason codes to 128 bytes matching `[A-Za-z0-9][A-Za-z0-9._-]*`. References use the configured JSON normalization depth and byte limit with an absolute 256 KiB cap.
|
|
687
|
+
|
|
688
|
+
Controllers query safe history metadata with one method:
|
|
689
|
+
|
|
690
|
+
```typescript
|
|
691
|
+
const info = await harness.getSessionReversionInfo(scopeId, sessionId);
|
|
692
|
+
for (const group of info.groups) {
|
|
693
|
+
console.log(
|
|
694
|
+
group.runId,
|
|
695
|
+
group.kind,
|
|
696
|
+
group.visibility,
|
|
697
|
+
group.affectedWorkspaces,
|
|
698
|
+
group.affectedWorkspacesTruncated,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
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.
|
|
704
|
+
|
|
705
|
+
`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`, deterministic 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.
|
|
706
|
+
|
|
707
|
+
The protocol is durable and inspectable:
|
|
708
|
+
|
|
709
|
+
1. FlexHarness persists a `preparing` capture intent before calling `prepare()`, before model or tool execution. The provider must establish exclusive capture ownership for that `storageKey` and retain it until `release()` succeeds.
|
|
710
|
+
2. `inspectCapture()` returns `missing`, `prepared`, `finalized`, or `unknown`. A finalized V1 result includes `reference`; a finalized V2 result includes the complete tagged `outcome`. `missing` is safe only while the durable state is still `preparing`; a missing prepared or finalizing capture, an `unknown` result, or an inspection failure fences the namespace.
|
|
711
|
+
3. `finalize()` closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans foreground subagent effects, although child transcript records remain separate.
|
|
712
|
+
4. Before undo or redo, FlexHarness persists an apply write-ahead record. `inspectApply()` must report the exact durable outcome for the supplied `operationId`: `not-applied` means no effect occurred, `applied` means the complete effect occurred, and `unknown` means the provider cannot prove either result. FlexHarness calls `apply()` only for `not-applied`; after an apply error it inspects again, and an `unknown` result or inspection failure fences the namespace. Progress is persisted after each segment, and the transcript cursor moves only after the complete unit succeeds. Known zero-progress failures leave the cursor unchanged and can be retried; partial progress retains the write-ahead record and resumes after restart. Caller cancellation is honored until the first workspace segment makes progress; recovery then continues with fresh bounded maintenance signals until the unit and cursor commit. Providers must treat `operationId` idempotently.
|
|
713
|
+
5. `release()` relinquishes the capture after no-change or nonrevertible V2 finalization, branch commitment, retention or archive pruning, session deletion, or other durable removal. It must be idempotent: an unacknowledged release remains persisted and is retried before FlexHarness discards the reference.
|
|
714
|
+
|
|
715
|
+
Under `workspace-required`, a group is a barrier when any segment is pending, nonrevertible, or legacy transcript-only history. It is a candidate when at least one segment is revertible and none is a barrier; otherwise it is no-change. Only candidates can be traversed. No-change groups after a candidate travel with that candidate until the next candidate or barrier. Leading no-change groups remain visible. A retained barrier blocks older groups, while later candidates remain undoable. A mixed revertible/nonrevertible group is a barrier.
|
|
716
|
+
|
|
717
|
+
Inspection, recovery, finalization, and release use fresh maintenance signals bounded by the top-level `reversionMaintenanceTimeoutMs` option. It must be a positive safe integer no greater than 30 minutes. When omitted, FlexHarness uses `agentSessionPolicy.generationLeaseCleanupTimeoutMs` as a compatibility fallback, then defaults to 30 seconds when neither option is supplied. The agent-session setting continues to govern AgentSession generation-lease cleanup independently. Providers must observe every supplied signal and must serialize ownership for a storage namespace. A provider with the matching persisted `protocolVersion` must remain configured whenever a capture-backed session is reopened, retired, disposed, or deleted. Capture-backed recovery and deletion fail closed without it.
|
|
718
|
+
|
|
719
|
+
Workspace reversion is generic and application-defined. It does not reverse network, database, billing, or other side effects unless the provider deliberately captures them. References are normalized with `toolOutputLimits` and have an absolute 256 KiB encoded cap.
|
|
720
|
+
|
|
721
|
+
| `reversionLimits` field | Default | Hard maximum |
|
|
722
|
+
| --- | ---: | ---: |
|
|
723
|
+
| `maxCompletedTurns` | 100 | 1000 |
|
|
724
|
+
| `maxSegments` | 300 | 3000 |
|
|
725
|
+
| `maxExcludedRunIds` | 1000 | 10000 |
|
|
726
|
+
| `maxPendingReversionReleases` | 1000 | 10000 |
|
|
727
|
+
|
|
728
|
+
Every configured value must be an integer from 1 through its hard maximum. Pruning removes complete prefixes rather than splitting an undo unit. Excluded run IDs prevent a committed branch from re-entering model context, while pending releases retain provider ownership until acknowledgement.
|
|
729
|
+
|
|
730
|
+
`retireScope()` stops runtime ownership for the complete resolved storage namespace without deleting its durable snapshot. It does not load a namespace that has no cached or in-flight state. For loaded state, it preserves and waits for persistence that has already started, while later queued reads, writes, and run admissions reject with `FlexHarnessAbortError`. It cancels queued prompts and cancellable runs, rejects pending permissions, emits queue terminal events, waits for committing runs, terminal persistence, queue drains, tool-handle closure, and detached tool-provider cleanup, then purges queue status and clears and evicts the cached state. Failed cleanup ownership remains cached so a later `retireScope()` or `dispose()` call can retry it. Calls through storage-key aliases share the same retirement drain. A later call can load the durable namespace again after successful retirement if the application still resolves it.
|
|
731
|
+
|
|
732
|
+
Normal retirement-induced cancellation does not make `retireScope()` reject. Unexpected failures observed through run finalization or scoped cleanup are surfaced without dropping the resources that still require cleanup. One such failure is thrown directly; multiple failures are reported through `FlexHarnessRunError`. Calling retirement or disposal again retries retained cleanup ownership.
|
|
733
|
+
|
|
734
|
+
Applications removing a scope must stop and serialize new admission across every alias before calling `retireScope()`, await retirement, and only then remove or purge application-owned durable records. FlexHarness cannot discover aliases before the application resolver returns. Integrations must not use retirement itself as durable deletion.
|
|
735
|
+
|
|
736
|
+
## History And Audit Behavior
|
|
737
|
+
|
|
738
|
+
The model context is built by the session's canonical AgentSession event history as:
|
|
739
|
+
|
|
740
|
+
1. Previous canonically accepted generations.
|
|
741
|
+
2. The normalized current user message.
|
|
742
|
+
3. AgentSession's result messages.
|
|
743
|
+
|
|
744
|
+
A failed or cancelled prompt remains visible through `getMessages()`, with `failed` or `cancelled` status, but is not included in future model context. Canonical Agent events remain private and are not exposed by the session or message APIs.
|
|
745
|
+
|
|
746
|
+
Resolved model identity contains provider and model IDs plus optional display name and effective `variant`. The identity, including its variant, is attached to a failed assistant message when resolution completed before a later failure, matching the provider/model behavior. Prompt results contain it only on success.
|
|
747
|
+
|
|
748
|
+
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.
|
|
749
|
+
|
|
750
|
+
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.
|
|
751
|
+
|
|
752
|
+
## Permissions
|
|
753
|
+
|
|
754
|
+
Tools request permission through the run-scoped provider context:
|
|
755
|
+
|
|
756
|
+
```typescript
|
|
757
|
+
await context.requestPermission({
|
|
758
|
+
kind: 'filesystem.write',
|
|
759
|
+
description: 'Write generated files into the project',
|
|
760
|
+
toolCallId,
|
|
761
|
+
rememberKey: 'filesystem.write:project-output',
|
|
762
|
+
metadata: { target: 'generated/' },
|
|
763
|
+
});
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Pending requests are runtime-only and queryable with `listPendingPermissions()`. Every `IFlexPermissionRequest` carries the exact `sessionGenerationId` and `sessionGenerationSequence` of its run so a controller can fence replies against reused session IDs. A controller answers with:
|
|
767
|
+
|
|
768
|
+
- `once`: allow this request.
|
|
769
|
+
- `always`: allow and remember the request's `rememberKey` for this session.
|
|
770
|
+
- `reject`: reject the tool execution.
|
|
771
|
+
|
|
772
|
+
`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.
|
|
773
|
+
|
|
774
|
+
## Tool Output Safety
|
|
775
|
+
|
|
776
|
+
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.
|
|
777
|
+
|
|
778
|
+
`toolOutputLimits` in the complete setup above bounds traversal depth and encoded bytes. The normalizer enforces its byte allowance incrementally: oversized strings are replaced before entering output, and arrays/objects stop reading entries once only truncation metadata fits.
|
|
779
|
+
|
|
780
|
+
Streaming callbacks use run-local synchronous state rather than one persistence promise per source delta. Text and reasoning accumulate only in the run-local terminal projection while each source delta remains an immediate exact public event. Every distinct async-iterable tool output appears immediately as a bounded cumulative `part.updated` snapshot while the tool remains `running`, including the final yielded value before completion. Only the authoritative `part.completed` output enters the terminal projection, and failed or interrupted tools discard their transient output. `callbackLimits` bounds callback events, accumulated output bytes, and part count; overflow aborts internally with `FlexHarnessCallbackOverflowError` and the turn is recorded as failed. Reservation and terminal finalization are the normal persistence checkpoints, with permission state changes as explicit additional checkpoints.
|
|
781
|
+
|
|
782
|
+
Model resolver, tool provider, delegated run admission provider, AgentSession, tool execution, tool callback, tool cleanup, and run-persistence failures cross an untrusted error boundary. By default they become a fixed immutable `FlexHarnessExternalError` before completion rejection, persistence, events, or detached-cleanup reporting. Raw external messages and aggregate members are not retained. A failed `onToolCallFinish` callback stores and accounts for only the bounded projected message; it does not otherwise reject completion, although exceeding the configured callback limits still fails the run. Scope resolution and the initial store load happen before a run exists and remain outside this boundary.
|
|
783
|
+
|
|
784
|
+
`externalErrorProjector` receives one of `modelResolver`, `toolProvider`, `delegatedRunAdmissionProvider`, `agentSession`, `toolExecution`, `toolCallback`, `toolCleanup`, `persistence`, `slashCommand`, or `turnReversion` as its source. It may synchronously return an application-approved plain data object `{ name, message, code? }`, limited to a 128-byte name, 2048-byte message, and optional 128-byte code. Accessors, extra keys, throwing projectors, and malformed or oversized results fall back to the fixed error. Even exported FlexHarness error subclasses thrown by external integrations are reprojected. Internally created cancellation, callback-overflow, and permission errors retain their typed behavior.
|
|
785
|
+
|
|
786
|
+
`normalizeJsonValue()` is also exported for integrations that need the same conversion independently.
|
|
787
|
+
|
|
788
|
+
## Agent Runtime Operations
|
|
789
|
+
|
|
790
|
+
`pushRuntimeEvent()` appends a validated JSON event through the canonical AgentSession event store. It is intended for controller-owned context such as workspace changes or external notifications; invalid or non-JSON values fail before persistence.
|
|
791
|
+
|
|
792
|
+
Transactional tool calls persist an execution intent before the tool side effect starts. After an interrupted process, `listUncertainToolExecutions()` exposes intents whose outcome cannot be proven. A controller must inspect the external system and call `reconcileToolExecution()` with `executed`, `not-executed`, or `abandoned-unknown` before allowing dependent work to continue. Reconciliation output is normalized using the same tool-output limits.
|
|
793
|
+
|
|
794
|
+
`agentSessionPolicy` forwards bounded AgentSession session controls for context building, compaction, event retention, change-listener pressure, lease cleanup, archived transaction tombstones, and context-overflow retries. A configured `contextCompactor` receives the projected model messages, only the filtered model-visible covered events, AgentSession's existing `reason` and `abortSignal`, and the exact resolved `scopeId`, `scope`, `storageKey`, and `sessionId` for the invocation causing compaction. The invocation context remains isolated when aliases share one storage key, so integrations can resolve the correct model without global mutable state. If no events are eligible for compaction, `compactSession()` returns without calling the compactor or writing a compaction event; otherwise it writes the canonical event. `archiveSessionEvents()` moves events covered by that compaction into the configured Agent event archive store and returns public archive metadata.
|
|
795
|
+
|
|
796
|
+
`executionContextProvider` can construct a AgentSession execution context for each session. FlexHarness supplies the resolved scope, storage key, and the session's private job store. The public background APIs expose only execution ID, type, state, exit code, and timestamps; command payloads, stdout, and stderr remain private. The provider's optional `close()` is owned by session deletion, scope retirement, and harness disposal.
|
|
797
|
+
|
|
798
|
+
## Events
|
|
799
|
+
|
|
800
|
+
```typescript
|
|
801
|
+
const unsubscribe = harness.subscribe((event) => {
|
|
802
|
+
switch (event.type) {
|
|
803
|
+
case 'part.delta':
|
|
804
|
+
applyExactDelta(
|
|
805
|
+
event.sessionId,
|
|
806
|
+
event.messageIndex,
|
|
807
|
+
event.partIndex,
|
|
808
|
+
event.partType,
|
|
809
|
+
event.delta,
|
|
810
|
+
event.baseTextUtf8Bytes,
|
|
811
|
+
event.textUtf8Bytes,
|
|
812
|
+
);
|
|
813
|
+
break;
|
|
814
|
+
case 'permission.requested':
|
|
815
|
+
showPermission(event.request);
|
|
816
|
+
break;
|
|
817
|
+
case 'session.updated':
|
|
818
|
+
renderSession(event.session);
|
|
819
|
+
break;
|
|
820
|
+
case 'session.deleted':
|
|
821
|
+
removeSession(event.sessionId);
|
|
822
|
+
break;
|
|
823
|
+
case 'run.finished':
|
|
824
|
+
markRunFinished(event.runId, event.status);
|
|
825
|
+
break;
|
|
826
|
+
case 'session.history.changed':
|
|
827
|
+
refreshSelectedSession(event.sessionId);
|
|
828
|
+
break;
|
|
829
|
+
case 'prompt.queued':
|
|
830
|
+
case 'prompt.started':
|
|
831
|
+
case 'prompt.running':
|
|
832
|
+
case 'prompt.finished':
|
|
833
|
+
renderQueueStatus(event.queueId, event.entry.status);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
unsubscribe();
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
|
|
842
|
+
|
|
843
|
+
`part.started`, `part.updated`, and `part.completed` are snapshot events with a complete immutable `part`. A newly streamed text part emits `part.started` with empty text before its first delta; reasoning parts also start empty. `part.delta` is a separate delta-only event: it has no cumulative `part`, and carries `partType`, the required exact source `delta`, and `baseTextUtf8Bytes`/`textUtf8Bytes` for the cumulative text before and after that delta. The counters remain correct when a UTF-16 surrogate pair is split across callbacks. Exact deltas are never truncated, including a single delta above the 96 KiB message-transfer text limit. `part.updated` remains a cumulative replacement snapshot for running tool output or metadata, not a text delta. `session.history.changed` carries `direction: 'undo' | 'redo' | 'branch'`; `runId` is present for undo and redo. Events contain public IDs, exact delta payloads, and public snapshots only; they do not expose prompt payloads, the resolved scope object, storage key, model object, provider options, or raw storage key.
|
|
844
|
+
|
|
845
|
+
Part events narrow through the exported `TFlexPartEvent` union. `IFlexPartEventBase` contains their shared coordinates, `IFlexPartSnapshotEvent` owns snapshot events and their complete `part`, and `IFlexPartDeltaEvent` owns exact delta-only events and their UTF-8 counters.
|
|
846
|
+
|
|
847
|
+
### Migrating Part Events to 5.x
|
|
848
|
+
|
|
849
|
+
Version `5.x` replaces cumulative `part.delta` payloads with the exact delta-only contract above. Consumers must stop reading `event.part` from `part.delta`; use `event.partType`, `event.delta`, `event.baseTextUtf8Bytes`, and `event.textUtf8Bytes`, then hydrate or settle from the complete `part` carried by snapshot events. `IFlexPartChangedEvent` has been removed; use `TFlexPartEvent`, `IFlexPartSnapshotEvent`, or `IFlexPartDeltaEvent` according to the required narrowing. New streamed text and reasoning parts start with empty text, so consumers must apply subsequent deltas in sequence within that harness instance.
|
|
850
|
+
|
|
851
|
+
## Stores
|
|
852
|
+
|
|
853
|
+
Current FlexHarness persistence is separated by trust and lifecycle domain through `IFlexHarnessStores`:
|
|
854
|
+
|
|
855
|
+
- `scopes`: session metadata and deletion tombstones for a resolved storage namespace.
|
|
856
|
+
- `projections`: public audit messages and hidden terminal stages per session.
|
|
857
|
+
- `permissions`: remembered permission keys per session.
|
|
858
|
+
- `projectManagement`: generation-fenced task, goal, and scratchpad state per session.
|
|
859
|
+
- `agentEvents`: canonical private AgentSession events and archives per session.
|
|
860
|
+
- `jobs`: private background execution state per session.
|
|
861
|
+
|
|
862
|
+
`InMemoryFlexHarnessStores` implements all six required domains with revision-based compare-and-swap behavior for tests and ephemeral processes. It is the default when `stores` is omitted. Custom `IFlexHarnessStores` implementations must provide `projectManagement` even when project-management tools are disabled, because deletion cleanup always writes the generation fence.
|
|
863
|
+
|
|
864
|
+
Custom Agent event and job providers may implement `releaseSession(storageKey, sessionId)` to release session-bound wrappers, handles, or caches without deleting durable data. FlexHarness calls these hooks only after the corresponding AgentSession or execution context has released runtime ownership. A failed release remains owned for a later retirement or disposal retry. `deleteSession()` and `deleteSessionGenerationCohort()` are the separate destructive operations for durable session data.
|
|
865
|
+
|
|
866
|
+
`JsonFileFlexHarnessStores` stores the domains in separate `scopes`, `projections`, `permissions`, `projectManagement`, `events`, `archives`, and `jobs` directories. Storage and session identifiers are SHA-256 hashed for filenames. Passing the store bundle supplies lifecycle persistence but does not enable any built-in tool. It provides:
|
|
867
|
+
|
|
868
|
+
- Strict domain-specific schema validation and optimistic revisions.
|
|
869
|
+
- Static process-wide queues shared by all store instances for the same absolute file.
|
|
870
|
+
- Revision re-reads inside the queue before every save.
|
|
871
|
+
- Atomic temporary-file write, file fsync, rename, and parent-directory fsync.
|
|
872
|
+
- Directory mode `0700` and file mode `0600`, including existing paths.
|
|
873
|
+
- Stale temporary-file cleanup and strict snapshot validation.
|
|
874
|
+
|
|
875
|
+
After every harness using a `JsonFileFlexHarnessStores` instance has been disposed and no store operation remains active, call `await stores.dispose()` to retry and drain any file handle whose earlier close failed. A failed store disposal retains that handle so the call can be retried.
|
|
876
|
+
|
|
877
|
+
The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core `IFlexHarnessStores` domain and `stores.projectManagement` must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
|
|
878
|
+
|
|
879
|
+
Direct store operations and non-run session mutations surface conflicts as `FlexHarnessStoreConflictError` or the corresponding AgentSession store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as `FlexHarnessStoreFormatError`. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces `FlexHarnessStoreCommitUncertainError` with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become `FlexHarnessExternalError`. FlexHarness does not merge conflicts.
|
|
880
|
+
|
|
881
|
+
Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as `accepted`, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
|
|
882
|
+
|
|
883
|
+
Projection stores accept schema version 1, 2, or 3 from `load()`, but every `save()` receives the current schema-3 shape. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively `revertible`, `no-change`, or `nonrevertible`; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
|
|
884
|
+
|
|
885
|
+
A loaded schema-1 projection has no reversion state. Schema-2 `workspaceCaptured` segments migrate as protocol-1 workspace/revertible history without discarding references; transcript-only segments migrate as protocol-1 transcript provenance. Under `workspace-required`, that legacy transcript history is a barrier. The next projection mutation writes schema 3. Custom stores must preserve strict compare-and-swap revisions across schema-1 and schema-2 read-upgrade-write cycles, including uncertain-save reconciliation.
|
|
886
|
+
|
|
887
|
+
## Migrating From 2.x
|
|
888
|
+
|
|
889
|
+
Version `3.x` replaces the single `IFlexHarnessStore` snapshot with the split stores above. Run migration while every process that can access the storage namespace is stopped.
|
|
890
|
+
|
|
891
|
+
```typescript
|
|
892
|
+
import {
|
|
893
|
+
JsonFileFlexHarnessStores,
|
|
894
|
+
} from '@modelprofile.com/flexharness';
|
|
895
|
+
import {
|
|
896
|
+
migrateLegacyFlexHarnessSnapshot,
|
|
897
|
+
type IFlexLegacyHarnessSnapshot,
|
|
898
|
+
} from '@modelprofile.com/flexharness/migration';
|
|
899
|
+
|
|
900
|
+
const storageKey = 'account/project';
|
|
901
|
+
const legacySnapshot: IFlexLegacyHarnessSnapshot = await loadLegacySnapshot(storageKey);
|
|
902
|
+
const stores = new JsonFileFlexHarnessStores({
|
|
903
|
+
directory: '/var/lib/my-app/model-sessions-v3',
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
await migrateLegacyFlexHarnessSnapshot(storageKey, legacySnapshot, stores);
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
`loadLegacySnapshot()` is application-owned access to the snapshot written by the `2.x` store. The migration validates the complete source and every public run before writing. A run left streaming by a process crash is deterministically repaired to the same cancelled state that the `2.x` loader produced in memory. The migration then converts private model messages into generationless canonical Agent conversation events, records terminal AgentSession transactions for completed, failed, and cancelled public runs, and writes schema-3 projections with empty reversion state. Migrated history therefore remains visible and auditable but is not retroactively undoable; turns created after migration receive normal reversion segments and optional workspace captures. The migration preflights the scope, projection, permission, Agent event, and job destinations before any write, applies missing per-session domains first, and publishes scope discovery last. It is safe to rerun after no work, a completed prefix, or a complete migration when existing destination content is identical. It fails closed when a destination contains conflicting content or non-empty jobs. Keep the legacy snapshot until the migrated application has loaded and verified every storage namespace.
|
|
910
|
+
|
|
911
|
+
## Shutdown
|
|
912
|
+
|
|
913
|
+
Model and tool resolution share the run signal. Synchronous throws are observed as resolver failures; the first failure aborts that signal and finalizes immediately without waiting for an unresponsive sibling. A detached application or resource tool provider that resolves later is observed and every acquired handle is closed; disposal waits for that settlement and reports a late close failure.
|
|
914
|
+
|
|
915
|
+
Tool-handle close settles before a turn can be successful. After model generation resolves, FlexHarness stages its terminal projection before canonical acceptance; earlier execution failures are finalized as interrupted and then published from that durable outcome. Cleanup failure prevents canonical acceptance. If canonical finalization or public promotion fails, FlexHarness fences the namespace; the next load repairs public state from the durable canonical outcome and any hidden terminal stage.
|
|
916
|
+
|
|
917
|
+
`dispose()` is asynchronous and idempotent. It marks the harness closed, settles unresolved queue admissions, cancels queued prompts and cancellable active runs, rejects pending permissions, emits queue terminal events, waits for committing runs, queue drains, all run finalizers, state save tails, and tracked detached tool-provider cleanup, purges runtime queue records, then clears listeners. Loaded state caches are cleared after all cleanup succeeds; a failed drain retains its cache and cleanup ownership so a later `dispose()` call can retry it. Multiple run or cleanup failures are reported through `FlexHarnessRunError`.
|
|
918
|
+
|
|
919
|
+
If `dispose()` overlaps a storage namespace already being retired, both calls await the same storage drain and cleanup runs once. A retirement call begun after disposal starts rejects with `FlexHarnessClosedError`.
|
|
920
|
+
|
|
921
|
+
Cancellation is cooperative: model resolvers, tool providers, AgentSession model execution, tools, execution contexts, and cleanup functions must observe the supplied `AbortSignal` and settle tracked work. After a sibling resolver fails, FlexHarness deliberately does not wait for an unresponsive model resolver; a detached tool provider remains tracked because any late handle must be closed. A process host that needs a hard shutdown deadline must enforce that deadline outside FlexHarness and terminate only the process it owns.
|
|
922
|
+
|
|
923
|
+
## License and Legal Information
|
|
924
|
+
|
|
925
|
+
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
|
|
926
|
+
|
|
927
|
+
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
|
928
|
+
|
|
929
|
+
### Trademarks
|
|
930
|
+
|
|
931
|
+
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
|
932
|
+
|
|
933
|
+
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
|
934
|
+
|
|
935
|
+
### Company Information
|
|
936
|
+
|
|
937
|
+
Task Venture Capital GmbH<br>
|
|
938
|
+
Registered at District Court Bremen HRB 35230 HB, Germany
|
|
939
|
+
|
|
940
|
+
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
|
941
|
+
|
|
942
|
+
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|