@xemahq/agent-session-runtime 0.1.1
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/LICENSE +201 -0
- package/README.md +61 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/composer.d.ts +14 -0
- package/dist/lib/composer.d.ts.map +1 -0
- package/dist/lib/composer.js +595 -0
- package/dist/lib/composer.js.map +1 -0
- package/dist/lib/composition-workspace-manifest.d.ts +44 -0
- package/dist/lib/composition-workspace-manifest.d.ts.map +1 -0
- package/dist/lib/composition-workspace-manifest.js +143 -0
- package/dist/lib/composition-workspace-manifest.js.map +1 -0
- package/dist/lib/dispatch-contract.d.ts +48 -0
- package/dist/lib/dispatch-contract.d.ts.map +1 -0
- package/dist/lib/dispatch-contract.js +8 -0
- package/dist/lib/dispatch-contract.js.map +1 -0
- package/dist/lib/drift-detector.d.ts +40 -0
- package/dist/lib/drift-detector.d.ts.map +1 -0
- package/dist/lib/drift-detector.js +386 -0
- package/dist/lib/drift-detector.js.map +1 -0
- package/dist/lib/environment-resolver.d.ts +84 -0
- package/dist/lib/environment-resolver.d.ts.map +1 -0
- package/dist/lib/environment-resolver.js +181 -0
- package/dist/lib/environment-resolver.js.map +1 -0
- package/dist/lib/errors.d.ts +12 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +27 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/lifecycle-state.d.ts +23 -0
- package/dist/lib/lifecycle-state.d.ts.map +1 -0
- package/dist/lib/lifecycle-state.js +70 -0
- package/dist/lib/lifecycle-state.js.map +1 -0
- package/dist/lib/skill-bundle-template-resolver.d.ts +23 -0
- package/dist/lib/skill-bundle-template-resolver.d.ts.map +1 -0
- package/dist/lib/skill-bundle-template-resolver.js +54 -0
- package/dist/lib/skill-bundle-template-resolver.js.map +1 -0
- package/dist/lib/types.d.ts +141 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +3 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +45 -0
- package/src/index.ts +34 -0
- package/src/lib/composer.ts +1041 -0
- package/src/lib/composition-workspace-manifest.ts +432 -0
- package/src/lib/dispatch-contract.ts +156 -0
- package/src/lib/drift-detector.ts +497 -0
- package/src/lib/environment-resolver.ts +480 -0
- package/src/lib/errors.ts +43 -0
- package/src/lib/lifecycle-state.ts +139 -0
- package/src/lib/skill-bundle-template-resolver.ts +147 -0
- package/src/lib/types.ts +443 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── EnvironmentResolver — single source of truth for execution env ──
|
|
3
|
+
//
|
|
4
|
+
// Wraps the existing `WorkspaceImageComposer` (which produces mount
|
|
5
|
+
// plans) and adds the dimensions the manifest now declares: skills,
|
|
6
|
+
// MCP services, credentials, permissions, persistence paths, preview
|
|
7
|
+
// surface, sub-agents, default model, env vars, plus the resolved
|
|
8
|
+
// agent identity itself. Output is a `ResolvedEnvironment` — the
|
|
9
|
+
// snapshot every entry point (workflow agent.activity, interactive-
|
|
10
|
+
// session bootstrap) hands to `applyWorkerControlSetup`.
|
|
11
|
+
//
|
|
12
|
+
// Precedence chain (top wins):
|
|
13
|
+
//
|
|
14
|
+
// 1. ExplicitOverrides (session/run model override, workflow `with.model`)
|
|
15
|
+
// 2. SurfaceContext.surfaceDefaults (caller-derived surface knobs)
|
|
16
|
+
// 3. Workspace manifest (`spec.*`)
|
|
17
|
+
// 4. Intrinsic agent floor (enforced HERE — never overridable)
|
|
18
|
+
//
|
|
19
|
+
// The TOOL floor (`INTRINSIC_FLOOR_TOOLS`) is materialised on every
|
|
20
|
+
// resolution into `ResolvedEnvironment.intrinsicFloorTools`. The set is
|
|
21
|
+
// fixed and closed; nothing in the request can subtract from it.
|
|
22
|
+
// Sub-agent overrides are ADDITIVE, never destructive: an explicit
|
|
23
|
+
// override appends or refines the model on a slug; it cannot remove
|
|
24
|
+
// a manifest-declared sub-agent (and downstream the agent's intrinsic
|
|
25
|
+
// floor cannot be removed either).
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
INTRINSIC_FLOOR_TOOLS,
|
|
32
|
+
IntrinsicFloorTool,
|
|
33
|
+
} from '@xemahq/kernel-contracts/agent-composition';
|
|
34
|
+
import type { WorkspaceMountPlan } from '@xemahq/kernel-contracts/agent-workspace';
|
|
35
|
+
import {
|
|
36
|
+
ToolProviderKind,
|
|
37
|
+
type ToolSelectionEntry,
|
|
38
|
+
} from '@xemahq/kernel-contracts/mcp-tool';
|
|
39
|
+
import type { CompiledWorkspaceManifest } from '@xemahq/dsl/workspace-manifest';
|
|
40
|
+
import {
|
|
41
|
+
ManifestSurface,
|
|
42
|
+
type AgentRunRole,
|
|
43
|
+
type Briefcase,
|
|
44
|
+
type BriefcaseMcpTool,
|
|
45
|
+
type CompiledManifestCredential,
|
|
46
|
+
type CompiledManifestDisplay,
|
|
47
|
+
type CompiledManifestPermissions,
|
|
48
|
+
type CompiledManifestPersistence,
|
|
49
|
+
type CompiledManifestOutputSurface,
|
|
50
|
+
type CompiledManifestSkillRef,
|
|
51
|
+
type ModelRef,
|
|
52
|
+
type SubAgentBinding,
|
|
53
|
+
} from '@xemahq/kernel-contracts/workflow';
|
|
54
|
+
|
|
55
|
+
import { ManifestSurfaceMismatchError } from './errors';
|
|
56
|
+
import type { TemplateResolver, WorkspaceImageComposer } from './types';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Discriminated surface context — declares whether the resolver is
|
|
60
|
+
* being invoked from a workflow step or an interactive session. The
|
|
61
|
+
* resolver validates `manifest.metadata.surfaceCompat` against this
|
|
62
|
+
* value and fails fast on mismatch (per CLAUDE.md, no silent fallback).
|
|
63
|
+
*/
|
|
64
|
+
export type SurfaceContext =
|
|
65
|
+
| {
|
|
66
|
+
readonly kind: typeof ManifestSurface.WORKFLOW;
|
|
67
|
+
readonly orgId: string;
|
|
68
|
+
readonly projectId: string;
|
|
69
|
+
readonly runId: string;
|
|
70
|
+
readonly jobRunId: string;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
readonly kind: typeof ManifestSurface.AGENT_SESSION;
|
|
74
|
+
readonly orgId: string;
|
|
75
|
+
readonly projectId: string;
|
|
76
|
+
readonly sessionId: string;
|
|
77
|
+
readonly turnGen?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Per-call explicit overrides. These layer ON TOP of the manifest's
|
|
82
|
+
* declarations and the agent's intrinsic floor (which is enforced
|
|
83
|
+
* downstream — the resolver does not see it). Overrides should only
|
|
84
|
+
* carry values the caller actually wants to assert; absent fields
|
|
85
|
+
* inherit from the manifest.
|
|
86
|
+
*/
|
|
87
|
+
export interface ExplicitEnvironmentOverrides {
|
|
88
|
+
/** Replaces `manifest.agent.defaultModel` for this call. */
|
|
89
|
+
readonly model?: ModelRef;
|
|
90
|
+
/**
|
|
91
|
+
* Additive — appended after manifest-declared sub-agents. Duplicate
|
|
92
|
+
* slugs collapse with override winning (so a caller can refine the
|
|
93
|
+
* model on a manifest-declared delegate without removing it).
|
|
94
|
+
*/
|
|
95
|
+
readonly subAgents?: readonly SubAgentBinding[];
|
|
96
|
+
/**
|
|
97
|
+
* Replaces `manifest.agent.role` for this call. Used by surfaces
|
|
98
|
+
* that legitimately re-role the same manifest (e.g. a reviewer
|
|
99
|
+
* borrow of a builder manifest).
|
|
100
|
+
*/
|
|
101
|
+
readonly role?: AgentRunRole;
|
|
102
|
+
/**
|
|
103
|
+
* Replaces `manifest.agent.deliverableSpecRef` for this call.
|
|
104
|
+
*/
|
|
105
|
+
readonly deliverableSpecRef?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EnvironmentResolverRequest {
|
|
109
|
+
readonly manifest: CompiledWorkspaceManifest;
|
|
110
|
+
readonly surface: SurfaceContext;
|
|
111
|
+
readonly overrides?: ExplicitEnvironmentOverrides;
|
|
112
|
+
/** Free-form context threaded to mount-source extractors. */
|
|
113
|
+
readonly agentContext: Readonly<Record<string, unknown>>;
|
|
114
|
+
/** Optional template resolver for seed-file template entries. */
|
|
115
|
+
readonly templateResolver?: TemplateResolver;
|
|
116
|
+
/**
|
|
117
|
+
* Kernel-shipped System-scope skill slugs to PREPEND to the mounted
|
|
118
|
+
* skill list. The resolver forwards this verbatim to the composer
|
|
119
|
+
* (see `ComposeWorkspaceImageRequest.systemSkillSlugs`). The caller
|
|
120
|
+
* is the entry-point service (workflow agent.activity, agent-session
|
|
121
|
+
* bootstrap) — it fetches the list from `skill-registry-api` once
|
|
122
|
+
* per dispatch. Omitted / empty = no System layer mounted (e.g.
|
|
123
|
+
* unit tests).
|
|
124
|
+
*/
|
|
125
|
+
readonly systemSkillSlugs?: readonly string[];
|
|
126
|
+
/**
|
|
127
|
+
* Optional MCP stanza resolver (Phase D1). Translates the manifest's
|
|
128
|
+
* `toolSelection` into a `mcpServers` map suitable for direct write
|
|
129
|
+
* into `opencode.jsonc:mcp`. When absent, the resolved environment
|
|
130
|
+
* carries no MCP stanzas — appropriate for workflow surfaces today
|
|
131
|
+
* (no MCP support) and tests. Session-api wires its
|
|
132
|
+
* `SessionToolResolverService` in via this hook so the same
|
|
133
|
+
* snapshot covers MCP alongside agents + skills + permissions.
|
|
134
|
+
*/
|
|
135
|
+
readonly mcpResolver?: McpStanzaResolver;
|
|
136
|
+
/**
|
|
137
|
+
* Run-scoped Briefcase from `ActionBaseInput.briefcase`. The resolver
|
|
138
|
+
* unions `briefcase.mcpTools[]` with `manifest.toolSelection` before
|
|
139
|
+
* calling the mcpResolver and emitting `resolved.toolSelection`,
|
|
140
|
+
* and threads the briefcase through to the composer for upload /
|
|
141
|
+
* reference mount entries. Workflow declarations are never
|
|
142
|
+
* SUBTRACTED — briefcase tools are strictly additive.
|
|
143
|
+
*/
|
|
144
|
+
readonly briefcase?: Briefcase;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Kernel-tier abstraction for "given a tool selection + tenancy +
|
|
149
|
+
* session context, return the `mcpServers` stanza opencode.jsonc
|
|
150
|
+
* expects". Implementations live in Platform packages
|
|
151
|
+
* (`SessionToolResolverService` in `agent-session-api`) so the
|
|
152
|
+
* kernel never imports mcp-gateway-api directly.
|
|
153
|
+
*/
|
|
154
|
+
export interface McpStanzaResolver {
|
|
155
|
+
resolve(
|
|
156
|
+
selection: readonly ToolSelectionEntry[],
|
|
157
|
+
context: McpStanzaResolverContext,
|
|
158
|
+
): Promise<Record<string, unknown>>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface McpStanzaResolverContext {
|
|
162
|
+
readonly orgId: string;
|
|
163
|
+
readonly projectId: string;
|
|
164
|
+
readonly sessionId?: string;
|
|
165
|
+
readonly actorSubject?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fully resolved execution environment — the snapshot every entry
|
|
170
|
+
* point hands to `applyWorkerControlSetup`. Deterministic in
|
|
171
|
+
* (manifest, surface, overrides, agentContext): `hash` is a sha256 of
|
|
172
|
+
* the canonical-form JSON so resume drift detection can compare two
|
|
173
|
+
* resolutions cheaply.
|
|
174
|
+
*/
|
|
175
|
+
export interface ResolvedEnvironment {
|
|
176
|
+
readonly surface: ManifestSurface;
|
|
177
|
+
readonly manifest: {
|
|
178
|
+
readonly slug: string;
|
|
179
|
+
readonly version: string;
|
|
180
|
+
};
|
|
181
|
+
readonly display: CompiledManifestDisplay;
|
|
182
|
+
readonly agent: {
|
|
183
|
+
readonly slug: string;
|
|
184
|
+
readonly role: AgentRunRole;
|
|
185
|
+
readonly deliverableSpecRef?: string;
|
|
186
|
+
readonly model?: ModelRef;
|
|
187
|
+
};
|
|
188
|
+
readonly subAgents: readonly SubAgentBinding[];
|
|
189
|
+
readonly skills: readonly CompiledManifestSkillRef[];
|
|
190
|
+
readonly toolSelection: readonly ToolSelectionEntry[];
|
|
191
|
+
/**
|
|
192
|
+
* Baseline tools every agent receives regardless of manifest — `bash`,
|
|
193
|
+
* `read`, `write`, `edit`, `grep`, `glob`, `ls`, `task`, `todo_write`,
|
|
194
|
+
* `memory`. Fixed, closed, and not overridable: the resolver writes
|
|
195
|
+
* the same set on every resolution. Downstream consumers (worker
|
|
196
|
+
* activity, session-bundle apply) merge this into the agent's runtime
|
|
197
|
+
* tool surface before handing to OpenCode. Strings are the wire-shape
|
|
198
|
+
* tool names (members of the `IntrinsicFloorTool` enum).
|
|
199
|
+
*/
|
|
200
|
+
readonly intrinsicFloorTools: readonly IntrinsicFloorTool[];
|
|
201
|
+
readonly credentials: readonly CompiledManifestCredential[];
|
|
202
|
+
readonly permissions: CompiledManifestPermissions;
|
|
203
|
+
readonly persistence: CompiledManifestPersistence;
|
|
204
|
+
readonly outputSurface: CompiledManifestOutputSurface;
|
|
205
|
+
readonly mountPlan: WorkspaceMountPlan;
|
|
206
|
+
readonly env: readonly { readonly name: string; readonly value: string }[];
|
|
207
|
+
/**
|
|
208
|
+
* Phase D1: resolved `mcpServers` map ready to drop into
|
|
209
|
+
* `opencode.jsonc:mcp`. Empty `{}` when the manifest declares no
|
|
210
|
+
* toolSelection or when the request omits an `mcpResolver`. Keeps
|
|
211
|
+
* the snapshot self-contained so downstream callers (workflow
|
|
212
|
+
* activity, session-api bundle apply) do not have to re-resolve
|
|
213
|
+
* MCP independently.
|
|
214
|
+
*/
|
|
215
|
+
readonly mcpServers: Readonly<Record<string, unknown>>;
|
|
216
|
+
/**
|
|
217
|
+
* Deterministic sha256 of the canonical-form JSON of every other
|
|
218
|
+
* field. Drift detection diffs the structured fields; this hash is
|
|
219
|
+
* a cheap "are we different at all?" early exit.
|
|
220
|
+
*/
|
|
221
|
+
readonly hash: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface EnvironmentResolver {
|
|
225
|
+
resolve(req: EnvironmentResolverRequest): Promise<ResolvedEnvironment>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export class DefaultEnvironmentResolver implements EnvironmentResolver {
|
|
229
|
+
constructor(private readonly composer: WorkspaceImageComposer) {}
|
|
230
|
+
|
|
231
|
+
async resolve(req: EnvironmentResolverRequest): Promise<ResolvedEnvironment> {
|
|
232
|
+
this.assertSurfaceCompat(req.manifest, req.surface);
|
|
233
|
+
|
|
234
|
+
const role = req.overrides?.role ?? req.manifest.agent.role;
|
|
235
|
+
const deliverableSpecRef =
|
|
236
|
+
req.overrides?.deliverableSpecRef ?? req.manifest.agent.deliverableSpecRef;
|
|
237
|
+
|
|
238
|
+
const mountPlan = await this.composer.compose({
|
|
239
|
+
orgId: req.surface.orgId,
|
|
240
|
+
projectId: req.surface.projectId,
|
|
241
|
+
manifest: req.manifest,
|
|
242
|
+
agentContext: req.agentContext,
|
|
243
|
+
...(req.surface.kind === ManifestSurface.WORKFLOW
|
|
244
|
+
? {
|
|
245
|
+
workflowRun: {
|
|
246
|
+
runId: req.surface.runId,
|
|
247
|
+
jobRunId: req.surface.jobRunId,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
: {
|
|
251
|
+
interactive: {
|
|
252
|
+
sessionId: req.surface.sessionId,
|
|
253
|
+
...(req.surface.turnGen !== undefined
|
|
254
|
+
? { turnGen: req.surface.turnGen }
|
|
255
|
+
: {}),
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
agentOverrides: {
|
|
259
|
+
role,
|
|
260
|
+
...(deliverableSpecRef !== undefined ? { deliverableSpecRef } : {}),
|
|
261
|
+
},
|
|
262
|
+
...(req.templateResolver !== undefined
|
|
263
|
+
? { templateResolver: req.templateResolver }
|
|
264
|
+
: {}),
|
|
265
|
+
...(req.systemSkillSlugs !== undefined
|
|
266
|
+
? { systemSkillSlugs: req.systemSkillSlugs }
|
|
267
|
+
: {}),
|
|
268
|
+
...(req.briefcase !== undefined ? { briefcase: req.briefcase } : {}),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const subAgents = mergeSubAgents(
|
|
272
|
+
req.manifest.agent.subAgents,
|
|
273
|
+
req.overrides?.subAgents,
|
|
274
|
+
);
|
|
275
|
+
const model = req.overrides?.model ?? req.manifest.agent.defaultModel;
|
|
276
|
+
|
|
277
|
+
const toolSelection = mergeBriefcaseToolSelection(
|
|
278
|
+
req.manifest.toolSelection,
|
|
279
|
+
req.briefcase?.mcpTools,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Phase H.2: capability federation. When an `mcpResolver` is wired
|
|
283
|
+
// (session-boot, workflow-runtime-worker) the resolver ALWAYS runs —
|
|
284
|
+
// the returned stanza is the fixed three meta-tool surface
|
|
285
|
+
// (`xema-capabilities-{list,describe,invoke}`), regardless of
|
|
286
|
+
// selection. `toolSelection` is still threaded through for snapshot
|
|
287
|
+
// provenance / audit, but the wire-side resolver ignores it. Empty
|
|
288
|
+
// `{}` only when no resolver is wired (unit tests + workflow surfaces
|
|
289
|
+
// that don't talk to mcp-gateway yet).
|
|
290
|
+
let mcpServers: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
291
|
+
if (req.mcpResolver) {
|
|
292
|
+
const ctx: McpStanzaResolverContext =
|
|
293
|
+
req.surface.kind === ManifestSurface.AGENT_SESSION
|
|
294
|
+
? {
|
|
295
|
+
orgId: req.surface.orgId,
|
|
296
|
+
projectId: req.surface.projectId,
|
|
297
|
+
sessionId: req.surface.sessionId,
|
|
298
|
+
}
|
|
299
|
+
: {
|
|
300
|
+
orgId: req.surface.orgId,
|
|
301
|
+
projectId: req.surface.projectId,
|
|
302
|
+
};
|
|
303
|
+
mcpServers = await req.mcpResolver.resolve(toolSelection, ctx);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const resolved: Omit<ResolvedEnvironment, 'hash'> = {
|
|
307
|
+
surface: req.surface.kind,
|
|
308
|
+
manifest: {
|
|
309
|
+
slug: req.manifest.slug,
|
|
310
|
+
version: req.manifest.version,
|
|
311
|
+
},
|
|
312
|
+
display: req.manifest.display,
|
|
313
|
+
agent: {
|
|
314
|
+
slug: req.manifest.agent.slug,
|
|
315
|
+
role,
|
|
316
|
+
...(deliverableSpecRef !== undefined ? { deliverableSpecRef } : {}),
|
|
317
|
+
...(model !== undefined ? { model } : {}),
|
|
318
|
+
},
|
|
319
|
+
subAgents,
|
|
320
|
+
skills: req.manifest.skills,
|
|
321
|
+
toolSelection,
|
|
322
|
+
// Floor is a fixed, closed set — emitted on every resolution. The
|
|
323
|
+
// request cannot subtract from it; the manifest cannot subtract
|
|
324
|
+
// from it. Downstream consumers MUST merge this set with the
|
|
325
|
+
// agent's authored permission block before handing to OpenCode.
|
|
326
|
+
intrinsicFloorTools: INTRINSIC_FLOOR_TOOLS,
|
|
327
|
+
credentials: req.manifest.credentials,
|
|
328
|
+
permissions: req.manifest.permissions,
|
|
329
|
+
persistence: req.manifest.persistence,
|
|
330
|
+
outputSurface: req.manifest.outputSurface,
|
|
331
|
+
mountPlan,
|
|
332
|
+
env: req.manifest.env,
|
|
333
|
+
mcpServers,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return { ...resolved, hash: hashResolved(resolved) };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private assertSurfaceCompat(
|
|
340
|
+
manifest: CompiledWorkspaceManifest,
|
|
341
|
+
surface: SurfaceContext,
|
|
342
|
+
): void {
|
|
343
|
+
if (!manifest.surfaceCompat.includes(surface.kind)) {
|
|
344
|
+
throw new ManifestSurfaceMismatchError(
|
|
345
|
+
`Manifest '${manifest.slug}@${manifest.version}' declares surfaceCompat=[${manifest.surfaceCompat.join(', ')}] but was resolved on '${surface.kind}'.`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Merge manifest-declared sub-agents with caller-supplied overrides.
|
|
353
|
+
* Overrides are additive: matching slugs collapse with override
|
|
354
|
+
* winning (so the caller can refine a manifest-declared delegate's
|
|
355
|
+
* model without redeclaring its alias). Insertion order is preserved
|
|
356
|
+
* so downstream `applyWorkerControlSetup` materialises a deterministic
|
|
357
|
+
* agent list.
|
|
358
|
+
*/
|
|
359
|
+
/**
|
|
360
|
+
* Exported so callers that materialise a session before holding a full
|
|
361
|
+
* `ResolvedEnvironment` (the workflow activity's pre-resolve setup
|
|
362
|
+
* call, the agent-session resume path) can compose the same
|
|
363
|
+
* authoritative sub-agent list the resolver would produce. Single
|
|
364
|
+
* source of truth — pickers MUST NOT roll their own merge.
|
|
365
|
+
*/
|
|
366
|
+
export function mergeSubAgents(
|
|
367
|
+
base: readonly SubAgentBinding[],
|
|
368
|
+
overrides: readonly SubAgentBinding[] | undefined,
|
|
369
|
+
): readonly SubAgentBinding[] {
|
|
370
|
+
if (!overrides || overrides.length === 0) {return base;}
|
|
371
|
+
const overrideBySlug = new Map<string, SubAgentBinding>(
|
|
372
|
+
overrides.map((s) => [s.slug, s]),
|
|
373
|
+
);
|
|
374
|
+
const baseSlugs = new Set<string>();
|
|
375
|
+
const out: SubAgentBinding[] = [];
|
|
376
|
+
for (const b of base) {
|
|
377
|
+
baseSlugs.add(b.slug);
|
|
378
|
+
out.push(overrideBySlug.get(b.slug) ?? b);
|
|
379
|
+
}
|
|
380
|
+
for (const o of overrides) {
|
|
381
|
+
if (!baseSlugs.has(o.slug)) {out.push(o);}
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Union the manifest's static toolSelection with the run-scoped
|
|
388
|
+
* briefcase tools. Briefcase entries are appended (manifest order
|
|
389
|
+
* wins for stable opencode key ordering); duplicates by
|
|
390
|
+
* `(kind, providerKind, resourceId, toolName?)` collapse.
|
|
391
|
+
*
|
|
392
|
+
* `BriefcaseMcpTool.providerKind` is wire-shape `string`; we validate
|
|
393
|
+
* against `ToolProviderKind` here so an out-of-enum value fails fast
|
|
394
|
+
* at dispatch instead of silently bypassing the manifest contract.
|
|
395
|
+
*/
|
|
396
|
+
function mergeBriefcaseToolSelection(
|
|
397
|
+
manifestSelection: readonly ToolSelectionEntry[],
|
|
398
|
+
briefcaseTools: readonly BriefcaseMcpTool[] | undefined,
|
|
399
|
+
): readonly ToolSelectionEntry[] {
|
|
400
|
+
if (!briefcaseTools || briefcaseTools.length === 0) return manifestSelection;
|
|
401
|
+
const out: ToolSelectionEntry[] = [...manifestSelection];
|
|
402
|
+
const seen = new Set<string>(out.map(toolSelectionKey));
|
|
403
|
+
for (const t of briefcaseTools) {
|
|
404
|
+
if (!isToolProviderKind(t.providerKind)) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`briefcase.mcpTools[].providerKind='${t.providerKind}' is not a known ToolProviderKind — dispatch must validate at the engine boundary.`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const entry: ToolSelectionEntry =
|
|
410
|
+
t.kind === 'tool'
|
|
411
|
+
? {
|
|
412
|
+
kind: 'tool',
|
|
413
|
+
providerKind: t.providerKind,
|
|
414
|
+
resourceId: t.resourceId,
|
|
415
|
+
toolName: requireToolName(t),
|
|
416
|
+
}
|
|
417
|
+
: {
|
|
418
|
+
kind: 'provider',
|
|
419
|
+
providerKind: t.providerKind,
|
|
420
|
+
resourceId: t.resourceId,
|
|
421
|
+
};
|
|
422
|
+
const key = toolSelectionKey(entry);
|
|
423
|
+
if (seen.has(key)) continue;
|
|
424
|
+
seen.add(key);
|
|
425
|
+
out.push(entry);
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function toolSelectionKey(entry: ToolSelectionEntry): string {
|
|
431
|
+
return entry.kind === 'tool'
|
|
432
|
+
? `tool:${entry.providerKind}:${entry.resourceId}:${entry.toolName}`
|
|
433
|
+
: `provider:${entry.providerKind}:${entry.resourceId}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isToolProviderKind(value: string): value is ToolProviderKind {
|
|
437
|
+
return (Object.values(ToolProviderKind) as string[]).includes(value);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function requireToolName(t: BriefcaseMcpTool): string {
|
|
441
|
+
if (t.toolName === undefined || t.toolName.length === 0) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`briefcase.mcpTools[].toolName is required when kind='tool' (providerKind='${t.providerKind}', resourceId='${t.resourceId}').`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return t.toolName;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Canonical-form sha256 of the resolved environment minus the hash
|
|
451
|
+
* itself. Used for cheap drift detection: if two snapshots hash to the
|
|
452
|
+
* same value the structured diff is guaranteed to be empty.
|
|
453
|
+
*
|
|
454
|
+
* Excludes `mountPlan` from the hash because mount-plan composition
|
|
455
|
+
* embeds caller-supplied lease keys / timestamps that don't actually
|
|
456
|
+
* change the environment shape — the comparator focuses on what the
|
|
457
|
+
* manifest declares, not on transient plan-side identifiers. Mount-
|
|
458
|
+
* level drift surfaces through the structured diff instead.
|
|
459
|
+
*/
|
|
460
|
+
function hashResolved(
|
|
461
|
+
resolved: Omit<ResolvedEnvironment, 'hash'>,
|
|
462
|
+
): string {
|
|
463
|
+
const { mountPlan: _mountPlan, ...rest } = resolved;
|
|
464
|
+
return createHash('sha256').update(canonicalJson(rest)).digest('hex');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function canonicalJson(value: unknown): string {
|
|
468
|
+
if (value === null || typeof value !== 'object') {
|
|
469
|
+
return JSON.stringify(value);
|
|
470
|
+
}
|
|
471
|
+
if (Array.isArray(value)) {
|
|
472
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
473
|
+
}
|
|
474
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
475
|
+
.filter(([, v]) => v !== undefined)
|
|
476
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
477
|
+
return `{${entries
|
|
478
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`)
|
|
479
|
+
.join(',')}}`;
|
|
480
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AWP_V1_SPEC } from '@xemahq/kernel-contracts/agent-workspace';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by the composer when a manifest references an AWP slot key
|
|
5
|
+
* that is not declared in `AWP_V1_SPEC.slots`. Fail-fast — the
|
|
6
|
+
* alternative (silent skip) yields an incomplete workspace with no
|
|
7
|
+
* signal to the operator, masking typos and stale slot names from
|
|
8
|
+
* older spec versions.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Thrown by `DefaultEnvironmentResolver.resolve()` when the resolved
|
|
12
|
+
* manifest's `metadata.surfaceCompat` does not include the surface
|
|
13
|
+
* the resolver is being invoked on (e.g. a workflow step pointing at
|
|
14
|
+
* a manifest declared `surfaceCompat: [agent-session]`). Fail-
|
|
15
|
+
* fast — the alternative is a silently broken dispatch where the
|
|
16
|
+
* agent boots on a surface the manifest authoring time never
|
|
17
|
+
* considered.
|
|
18
|
+
*/
|
|
19
|
+
export class ManifestSurfaceMismatchError extends Error {
|
|
20
|
+
override readonly name = 'ManifestSurfaceMismatchError';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UnknownSlotError extends Error {
|
|
24
|
+
override readonly name = 'UnknownSlotError';
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
readonly slotKey: string,
|
|
28
|
+
readonly origin:
|
|
29
|
+
| 'manifest.mounts'
|
|
30
|
+
| 'manifest.seedFiles'
|
|
31
|
+
| 'rendered.mounts'
|
|
32
|
+
| 'inputArtifacts',
|
|
33
|
+
readonly manifestSlug: string,
|
|
34
|
+
readonly manifestVersion: string,
|
|
35
|
+
) {
|
|
36
|
+
super(
|
|
37
|
+
`Workspace manifest '${manifestSlug}@${manifestVersion}' references slot '${slotKey}' from ${origin}, ` +
|
|
38
|
+
`but AWP_V1_SPEC declares no such slot. Known slots: [${AWP_V1_SPEC.slots
|
|
39
|
+
.map((s) => s.key)
|
|
40
|
+
.join(', ')}].`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── Agent-session lifecycle state machine ──
|
|
3
|
+
//
|
|
4
|
+
// Pure enums + transitions. Mirrors the `SessionStatus` enum persisted by
|
|
5
|
+
// the agent-session-api service; the worker imports from here (the
|
|
6
|
+
// service's persistence-layer enums are not importable across the apps
|
|
7
|
+
// boundary).
|
|
8
|
+
//
|
|
9
|
+
// Single source of truth for "can this session be paused right now?".
|
|
10
|
+
// Both interactive-session callers AND the workflow-runtime-worker
|
|
11
|
+
// dispatch activity consult `canTransition()` before issuing the HTTP
|
|
12
|
+
// call so misuse fails fast at compile time rather than as a 409 from
|
|
13
|
+
// the service.
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lifecycle state of an agent-session row. Mirrors the persisted enum on
|
|
18
|
+
* the agent-session-api service. Closed set.
|
|
19
|
+
*/
|
|
20
|
+
export const SessionLifecycleState = {
|
|
21
|
+
creating: 'creating',
|
|
22
|
+
provisioning: 'provisioning',
|
|
23
|
+
active: 'active',
|
|
24
|
+
paused: 'paused',
|
|
25
|
+
recovering: 'recovering',
|
|
26
|
+
completing: 'completing',
|
|
27
|
+
completed: 'completed',
|
|
28
|
+
failed: 'failed',
|
|
29
|
+
archived: 'archived',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type SessionLifecycleStateValue =
|
|
33
|
+
(typeof SessionLifecycleState)[keyof typeof SessionLifecycleState];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Who created the session row. Mirrors the persisted `SessionOwnerKind`.
|
|
37
|
+
*
|
|
38
|
+
* - `interactive_session` — user-facing chat sessions
|
|
39
|
+
* - `pipeline_run` — workflow-runtime-worker redraft loop spines
|
|
40
|
+
* - `chat_thread` — reserved (no dispatcher today)
|
|
41
|
+
*
|
|
42
|
+
* The CI enum-parity check verifies this stays in lockstep with the
|
|
43
|
+
* persisted enum.
|
|
44
|
+
*/
|
|
45
|
+
export const SessionOwnerKind = {
|
|
46
|
+
interactive_session: 'interactive_session',
|
|
47
|
+
pipeline_run: 'pipeline_run',
|
|
48
|
+
chat_thread: 'chat_thread',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
export type SessionOwnerKindValue =
|
|
52
|
+
(typeof SessionOwnerKind)[keyof typeof SessionOwnerKind];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Per-state transition matrix. `canTransition(from, to)` returns true
|
|
56
|
+
* iff the transition is valid.
|
|
57
|
+
*
|
|
58
|
+
* Notable transitions:
|
|
59
|
+
* - `paused → recovering` (resume claim)
|
|
60
|
+
* - `recovering → active` (resume success)
|
|
61
|
+
* - `recovering → paused` (resume rollback)
|
|
62
|
+
* - `active → completing → completed` (terminal success)
|
|
63
|
+
* - any-non-terminal → `failed` (terminal failure)
|
|
64
|
+
* - `paused | completed | failed → archived` (cleanup sweep)
|
|
65
|
+
*/
|
|
66
|
+
const TRANSITIONS: Readonly<
|
|
67
|
+
Record<SessionLifecycleStateValue, ReadonlySet<SessionLifecycleStateValue>>
|
|
68
|
+
> = {
|
|
69
|
+
creating: new Set([
|
|
70
|
+
SessionLifecycleState.provisioning,
|
|
71
|
+
SessionLifecycleState.failed,
|
|
72
|
+
]),
|
|
73
|
+
provisioning: new Set([
|
|
74
|
+
SessionLifecycleState.active,
|
|
75
|
+
SessionLifecycleState.failed,
|
|
76
|
+
]),
|
|
77
|
+
active: new Set([
|
|
78
|
+
SessionLifecycleState.paused,
|
|
79
|
+
SessionLifecycleState.completing,
|
|
80
|
+
SessionLifecycleState.failed,
|
|
81
|
+
]),
|
|
82
|
+
paused: new Set([
|
|
83
|
+
SessionLifecycleState.recovering,
|
|
84
|
+
SessionLifecycleState.archived,
|
|
85
|
+
SessionLifecycleState.failed,
|
|
86
|
+
]),
|
|
87
|
+
recovering: new Set([
|
|
88
|
+
SessionLifecycleState.active,
|
|
89
|
+
SessionLifecycleState.paused,
|
|
90
|
+
SessionLifecycleState.failed,
|
|
91
|
+
]),
|
|
92
|
+
completing: new Set([
|
|
93
|
+
SessionLifecycleState.completed,
|
|
94
|
+
SessionLifecycleState.failed,
|
|
95
|
+
]),
|
|
96
|
+
completed: new Set([SessionLifecycleState.archived]),
|
|
97
|
+
failed: new Set([SessionLifecycleState.archived]),
|
|
98
|
+
archived: new Set(),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function canTransition(
|
|
102
|
+
from: SessionLifecycleStateValue,
|
|
103
|
+
to: SessionLifecycleStateValue,
|
|
104
|
+
): boolean {
|
|
105
|
+
return TRANSITIONS[from].has(to);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Terminal states — no further transitions possible (except the
|
|
110
|
+
* `completed | failed → archived` cleanup sweep). Callers use this to
|
|
111
|
+
* short-circuit "is this session done?" checks.
|
|
112
|
+
*/
|
|
113
|
+
export function isTerminal(state: SessionLifecycleStateValue): boolean {
|
|
114
|
+
return (
|
|
115
|
+
state === SessionLifecycleState.completed ||
|
|
116
|
+
state === SessionLifecycleState.failed ||
|
|
117
|
+
state === SessionLifecycleState.archived
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pausable states — the session has a live worker that can be told to
|
|
123
|
+
* snapshot. `provisioning` is intentionally excluded: the worker isn't
|
|
124
|
+
* ready yet, so a pause request would 409. Callers receiving such a
|
|
125
|
+
* 409 should retry with backoff once `active` is reached.
|
|
126
|
+
*/
|
|
127
|
+
export function isPausable(state: SessionLifecycleStateValue): boolean {
|
|
128
|
+
return state === SessionLifecycleState.active;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resumable states. `paused` is the canonical case. `failed` rows are
|
|
133
|
+
* NOT resumable from here — they require explicit operator action
|
|
134
|
+
* (the resume API would 409). `recovering` is a transient state owned
|
|
135
|
+
* by an in-flight resume; another caller observing it should back off.
|
|
136
|
+
*/
|
|
137
|
+
export function isResumable(state: SessionLifecycleStateValue): boolean {
|
|
138
|
+
return state === SessionLifecycleState.paused;
|
|
139
|
+
}
|