@intx/workflow-host 0.2.2
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 +176 -0
- package/README.md +287 -0
- package/dist/adapters/blob-substrate.d.ts +49 -0
- package/dist/adapters/blob-substrate.js +140 -0
- package/dist/adapters/repo-store.d.ts +39 -0
- package/dist/adapters/repo-store.js +344 -0
- package/dist/adapters/spawn-child.d.ts +74 -0
- package/dist/adapters/spawn-child.js +152 -0
- package/dist/adapters/step-invoker.d.ts +114 -0
- package/dist/adapters/step-invoker.js +360 -0
- package/dist/child/env-bootstrap.d.ts +56 -0
- package/dist/child/env-bootstrap.js +120 -0
- package/dist/child/from-process-env.d.ts +127 -0
- package/dist/child/from-process-env.js +183 -0
- package/dist/child/index.d.ts +9 -0
- package/dist/child/index.js +9 -0
- package/dist/child/outbound-mail-bridge.d.ts +36 -0
- package/dist/child/outbound-mail-bridge.js +143 -0
- package/dist/child/proxy-repo-store.d.ts +27 -0
- package/dist/child/proxy-repo-store.js +200 -0
- package/dist/child/run-child.d.ts +320 -0
- package/dist/child/run-child.js +900 -0
- package/dist/child/self-discovery.d.ts +29 -0
- package/dist/child/self-discovery.js +57 -0
- package/dist/child/substrate-write-bridge.d.ts +72 -0
- package/dist/child/substrate-write-bridge.js +188 -0
- package/dist/child/supervisor-backed-transport.d.ts +10 -0
- package/dist/child/supervisor-backed-transport.js +113 -0
- package/dist/child/warm-agent-cache.d.ts +78 -0
- package/dist/child/warm-agent-cache.js +112 -0
- package/dist/drain-controller.d.ts +37 -0
- package/dist/drain-controller.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ipc/control-channel.d.ts +336 -0
- package/dist/ipc/control-channel.js +532 -0
- package/dist/ipc/crypto.d.ts +46 -0
- package/dist/ipc/crypto.js +126 -0
- package/dist/ipc/envelope.d.ts +53 -0
- package/dist/ipc/envelope.js +88 -0
- package/dist/ipc/event-channel.d.ts +677 -0
- package/dist/ipc/event-channel.js +278 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +143 -0
- package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
- package/dist/mail-bus/hub-transport-adapter.js +76 -0
- package/dist/mail-bus/index.d.ts +1 -0
- package/dist/mail-bus/index.js +1 -0
- package/dist/seams/index.d.ts +3 -0
- package/dist/seams/index.js +3 -0
- package/dist/seams/scheduler-adapter.d.ts +3 -0
- package/dist/seams/scheduler-adapter.js +24 -0
- package/dist/seams/scheduler.d.ts +94 -0
- package/dist/seams/scheduler.js +397 -0
- package/dist/seams/signal-channel.d.ts +74 -0
- package/dist/seams/signal-channel.js +304 -0
- package/dist/supervisor/cancel-signing.d.ts +68 -0
- package/dist/supervisor/cancel-signing.js +144 -0
- package/dist/supervisor/child-termination.d.ts +51 -0
- package/dist/supervisor/child-termination.js +76 -0
- package/dist/supervisor/credentials.d.ts +101 -0
- package/dist/supervisor/credentials.js +153 -0
- package/dist/supervisor/dispatch-attribution.d.ts +37 -0
- package/dist/supervisor/dispatch-attribution.js +114 -0
- package/dist/supervisor/drain-timeout.d.ts +127 -0
- package/dist/supervisor/drain-timeout.js +231 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/recycle.d.ts +212 -0
- package/dist/supervisor/recycle.js +440 -0
- package/dist/supervisor/run-event-compaction.d.ts +34 -0
- package/dist/supervisor/run-event-compaction.js +115 -0
- package/dist/supervisor/spawn-env.d.ts +39 -0
- package/dist/supervisor/spawn-env.js +36 -0
- package/dist/supervisor/supervisor.d.ts +202 -0
- package/dist/supervisor/supervisor.js +2244 -0
- package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
- package/dist/supervisor/terminal-broadcaster.js +184 -0
- package/dist/supervisor/types.d.ts +542 -0
- package/dist/supervisor/types.js +10 -0
- package/package.json +35 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Production `WorkflowRuntimeEnv.SpawnChildWorkflow` adapter.
|
|
2
|
+
//
|
|
3
|
+
// The runtime body sees the spawn callback shape: given a
|
|
4
|
+
// `definitionRef` (a workflow asset's repo id), a parent-allocated
|
|
5
|
+
// `childRunId`, the materialized child input, and parent attribution,
|
|
6
|
+
// settle once the child run reaches a terminal phase. The adapter
|
|
7
|
+
// itself does not execute the child workflow -- it resolves the
|
|
8
|
+
// `definitionRef` into a concrete `WorkflowDefinition` from the
|
|
9
|
+
// workflow repo's deploy ref, then delegates the spawn to a
|
|
10
|
+
// runtime-supplied `runChild` callback. The supervisor wires the
|
|
11
|
+
// callback against a child `WorkflowRuntimeEnv` and `runtimeRun`.
|
|
12
|
+
//
|
|
13
|
+
// Resolution path:
|
|
14
|
+
// 1. Build `RepoId { kind: "workflow", id: definitionRef }` against
|
|
15
|
+
// the substrate the deploy orchestrator wrote the workflow asset
|
|
16
|
+
// into.
|
|
17
|
+
// 2. Read `workflow.json` from the deploy ref's working tree at
|
|
18
|
+
// `getRepoDir(repoId)`. The deploy-time `writeTree` materializes
|
|
19
|
+
// the file on disk under the same path, so a flat `fs.readFile`
|
|
20
|
+
// against the substrate's repo dir gives the workflow envelope
|
|
21
|
+
// without dragging in a git object-database read for this commit.
|
|
22
|
+
// The sibling repo-store and blob-substrate adapters use the same
|
|
23
|
+
// working-tree-read pattern.
|
|
24
|
+
// 3. Parse as JSON, validate the envelope shape via
|
|
25
|
+
// `workflowDefinitionEnvelopeSchema`, and surface the parsed
|
|
26
|
+
// object as a `WorkflowDefinition`. The state-machine-narrowed
|
|
27
|
+
// primitives are validated by the runtime body downstream; the
|
|
28
|
+
// adapter does the structural-shape check the workflow-kind
|
|
29
|
+
// handler already enforces at push time so a tampered-on-disk
|
|
30
|
+
// tree still surfaces a clear error here rather than crashing
|
|
31
|
+
// deep inside the runtime.
|
|
32
|
+
//
|
|
33
|
+
// Drain coordination is handled by the supervisor's drain primitive
|
|
34
|
+
// (`packages/workflow-host/src/supervisor`), not by this adapter. The
|
|
35
|
+
// spawn path ships the basic shape the runtime body needs and leaves
|
|
36
|
+
// same-deployment vs cross-deployment drain semantics to the caller.
|
|
37
|
+
//
|
|
38
|
+
// Abort handling: if `signal` is already aborted on entry, the adapter
|
|
39
|
+
// short-circuits with a DOMException-shaped `AbortError`. The signal
|
|
40
|
+
// is propagated to the `runChild` callback so the child runtime can
|
|
41
|
+
// honor a parent-initiated cancellation. The adapter does not wrap
|
|
42
|
+
// the signal -- the same `AbortSignal` flows through so the abort
|
|
43
|
+
// reason attribution is unchanged across the boundary.
|
|
44
|
+
//
|
|
45
|
+
// Sub-namespace scoping (in-process recursion): the adapter is the
|
|
46
|
+
// resolution point that gives the runtime-supplied `runChild` callback
|
|
47
|
+
// a concrete `WorkflowDefinition` paired with the parent's allocated
|
|
48
|
+
// `childRunId`. The callback is expected to construct a child
|
|
49
|
+
// `WorkflowRuntimeEnv` whose `repoStore`/`blobs`/`signalChannel` route
|
|
50
|
+
// every per-run write through the SAME workflow-run repo the parent
|
|
51
|
+
// runs in, with the runtime body's per-call `runId` argument being the
|
|
52
|
+
// child's allocated `childRunId`. The workflow-run substrate's path
|
|
53
|
+
// shape is `runs/<runId>/events/<seq>.json` (and `runs/<runId>/blobs/`
|
|
54
|
+
// for the blob substrate), so feeding `childRunId` into the child env
|
|
55
|
+
// at the call boundary lands every child event under
|
|
56
|
+
// `runs/<childRunId>/...` of the parent's deployment workflow-run
|
|
57
|
+
// repo, sibling to the parent's own `runs/<parentRunId>/...` subtree.
|
|
58
|
+
// The adapter itself does not construct that env -- the supervisor's
|
|
59
|
+
// `runChild` does -- but the callback's input shape (`{ definition,
|
|
60
|
+
// childRunId, ... }`) is the seam that makes the scoping unambiguous
|
|
61
|
+
// at the boundary.
|
|
62
|
+
import { type } from "arktype";
|
|
63
|
+
import { workflowDefinitionEnvelopeSchema } from "@intx/hub-sessions/substrate";
|
|
64
|
+
const WORKFLOW_JSON_PATH = "workflow.json";
|
|
65
|
+
/**
|
|
66
|
+
* Construct the production `WorkflowRuntimeEnv.SpawnChildWorkflow`
|
|
67
|
+
* adapter. The substrate handle, the principal, the deploy ref, and
|
|
68
|
+
* the runtime-supplied child callback live in closure; the returned
|
|
69
|
+
* callable satisfies the runtime-env interface.
|
|
70
|
+
*/
|
|
71
|
+
export function createWorkflowSpawnChild(opts) {
|
|
72
|
+
return async ({ definitionRef, childRunId, input, parentRunId, parentStepId, signal, }) => {
|
|
73
|
+
if (signal.aborted) {
|
|
74
|
+
throw abortError(signal);
|
|
75
|
+
}
|
|
76
|
+
const definition = await resolveDefinition(opts, definitionRef);
|
|
77
|
+
// Re-check the abort signal after the resolution await. The
|
|
78
|
+
// caller can fire `signal.abort()` between the entry-time check
|
|
79
|
+
// and here; without this re-check the child callback would be
|
|
80
|
+
// invoked with an already-aborted signal and the parent's audit
|
|
81
|
+
// log would carry a spawn the adapter could have short-circuited
|
|
82
|
+
// before it ever reached the supervisor.
|
|
83
|
+
if (signal.aborted) {
|
|
84
|
+
throw abortError(signal);
|
|
85
|
+
}
|
|
86
|
+
const result = await opts.runChild({
|
|
87
|
+
definition,
|
|
88
|
+
definitionRef,
|
|
89
|
+
childRunId,
|
|
90
|
+
input,
|
|
91
|
+
parentRunId,
|
|
92
|
+
parentStepId,
|
|
93
|
+
signal,
|
|
94
|
+
});
|
|
95
|
+
return { terminalStatus: result.terminalStatus };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function resolveDefinition(opts, definitionRef) {
|
|
99
|
+
const repoId = { kind: "workflow", id: definitionRef };
|
|
100
|
+
const fs = await import("node:fs/promises");
|
|
101
|
+
const path = await import("node:path");
|
|
102
|
+
const dir = opts.substrate.getRepoDir(repoId);
|
|
103
|
+
const workflowPath = path.join(dir, WORKFLOW_JSON_PATH);
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = await fs.readFile(workflowPath, "utf8");
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
if (isErrnoNotFound(cause)) {
|
|
110
|
+
throw new Error(`workflow-runtime: spawn-child cannot resolve definitionRef ${JSON.stringify(definitionRef)}: ${WORKFLOW_JSON_PATH} not present under ${repoId.kind}/${repoId.id} on ${opts.deployRef}`, { cause });
|
|
111
|
+
}
|
|
112
|
+
throw cause;
|
|
113
|
+
}
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
catch (cause) {
|
|
119
|
+
throw new Error(`workflow-runtime: spawn-child read ${WORKFLOW_JSON_PATH} for ${repoId.kind}/${repoId.id} on ${opts.deployRef} is not valid JSON`, { cause });
|
|
120
|
+
}
|
|
121
|
+
const validated = workflowDefinitionEnvelopeSchema(parsed);
|
|
122
|
+
if (validated instanceof type.errors) {
|
|
123
|
+
throw new Error(`workflow-runtime: spawn-child ${WORKFLOW_JSON_PATH} for ${repoId.kind}/${repoId.id} on ${opts.deployRef} failed envelope validation: ${validated.summary}`);
|
|
124
|
+
}
|
|
125
|
+
// The envelope schema enforces the structural shape the workflow
|
|
126
|
+
// body and state machine consume; the discriminated narrow over
|
|
127
|
+
// every `Primitive` variant lives downstream (the runtime body
|
|
128
|
+
// walks the steps and dispatches per-kind). Re-deriving the
|
|
129
|
+
// primitive narrow here would duplicate `defineWorkflow`'s
|
|
130
|
+
// validation, and the workflow-kind handler already enforced the
|
|
131
|
+
// same envelope at push time.
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- WorkflowDefinition's primitive union is narrowed downstream by the runtime body; the envelope schema enforces the structural shape this adapter cares about
|
|
133
|
+
return validated;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Construct the rejection used when `signal.aborted` short-circuits.
|
|
137
|
+
* Mirrors the abort-error shape the sibling step-invoker adapter
|
|
138
|
+
* emits so consumers can `instanceof DOMException` /
|
|
139
|
+
* `name === "AbortError"` against a stable shape across the runtime.
|
|
140
|
+
*/
|
|
141
|
+
function abortError(signal) {
|
|
142
|
+
const reason = signal.reason;
|
|
143
|
+
if (reason instanceof Error)
|
|
144
|
+
return reason;
|
|
145
|
+
return new DOMException("aborted", "AbortError");
|
|
146
|
+
}
|
|
147
|
+
function isErrnoNotFound(cause) {
|
|
148
|
+
if (cause === null || typeof cause !== "object")
|
|
149
|
+
return false;
|
|
150
|
+
const code = cause.code;
|
|
151
|
+
return code === "ENOENT";
|
|
152
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type Agent, type AgentDefinition, type BaseEnv } from "@intx/agent";
|
|
2
|
+
import type { InferenceEvent, InferenceSource } from "@intx/types/runtime";
|
|
3
|
+
import type { StepInvokeRequest, StepInvoker, WorkflowAuthorizeFn } from "@intx/workflow";
|
|
4
|
+
import type { WarmAgentCache } from "../child/warm-agent-cache.js";
|
|
5
|
+
/**
|
|
6
|
+
* Per-step env contributions the caller of the adapter owns.
|
|
7
|
+
*
|
|
8
|
+
* The adapter constructs the agent's `authorize` closure from
|
|
9
|
+
* `WorkflowAuthorizeFn` + the per-call `AuthorizeContext`; everything
|
|
10
|
+
* else on `BaseEnv` is supplied here. `buildEnv` is invoked once per
|
|
11
|
+
* step and may be async so callers that allocate per-step resources
|
|
12
|
+
* (the per-run workdir, an isogit store rooted under it) can do so
|
|
13
|
+
* without a synchronous-only contract.
|
|
14
|
+
*/
|
|
15
|
+
export type StepEnvBase = Omit<BaseEnv, "authorize">;
|
|
16
|
+
export interface WorkflowStepInvokerOpts {
|
|
17
|
+
/**
|
|
18
|
+
* Workflow-level authorize callback. The adapter constructs a
|
|
19
|
+
* per-step `AuthorizeFn` closure that delegates here with the
|
|
20
|
+
* per-call `AuthorizeContext` already embedded, satisfying the
|
|
21
|
+
* agent harness's `AuthorizeFn<unknown>` slot.
|
|
22
|
+
*/
|
|
23
|
+
workflowAuthorize: WorkflowAuthorizeFn;
|
|
24
|
+
/**
|
|
25
|
+
* Build the per-step env minus `authorize`. Invoked once per step
|
|
26
|
+
* invocation; the returned env's `storage`, `workdir`, and other
|
|
27
|
+
* agent-runtime fields belong to that one step and are torn down
|
|
28
|
+
* with the agent.
|
|
29
|
+
*
|
|
30
|
+
* The callback receives the `StepInvokeRequest` so it can derive
|
|
31
|
+
* per-step paths (workdir under the run id, per-attempt storage
|
|
32
|
+
* roots) from the workflow runtime's vocabulary.
|
|
33
|
+
*/
|
|
34
|
+
buildEnv: (req: StepInvokeRequest) => Promise<StepEnvBase>;
|
|
35
|
+
/**
|
|
36
|
+
* Optional agent factory override. Defaults to `@intx/agent`'s
|
|
37
|
+
* `createAgent`. Tests inject a stub that returns a deterministic
|
|
38
|
+
* `Agent` without exercising the full reactor assembly.
|
|
39
|
+
*/
|
|
40
|
+
agentFactory?: <EnvReq extends BaseEnv>(def: AgentDefinition<EnvReq>, env: EnvReq) => Promise<Agent>;
|
|
41
|
+
/**
|
|
42
|
+
* Optional observability sink for the per-step agent's event stream.
|
|
43
|
+
* When supplied, the adapter subscribes the agent's `stream()` before
|
|
44
|
+
* `agent.send` so the inbound `inference.start` and the per-turn /
|
|
45
|
+
* tool-call events are captured, and forwards every `InferenceEvent`
|
|
46
|
+
* here. The subscription is torn down with the agent on every exit
|
|
47
|
+
* path, so no listener outlives the step.
|
|
48
|
+
*
|
|
49
|
+
* `onEvent` is a generic `(event: InferenceEvent) => void` sink: the
|
|
50
|
+
* adapter neither knows nor cares where the events go (a host wires
|
|
51
|
+
* it to its event-channel sender, the hub timeline, a test recorder).
|
|
52
|
+
* Forwarding is best-effort observability -- a throwing sink is
|
|
53
|
+
* logged and swallowed so a downstream consumer's failure cannot
|
|
54
|
+
* abort the step -- but the subscription's own teardown failures
|
|
55
|
+
* still surface.
|
|
56
|
+
*
|
|
57
|
+
* Omitting `onEvent` preserves the prior behaviour: the agent's
|
|
58
|
+
* `stream()` is never consumed and no events are forwarded.
|
|
59
|
+
*/
|
|
60
|
+
onEvent?: (event: InferenceEvent) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Warm-agent cache (design §3b). When supplied, the adapter runs in
|
|
63
|
+
* warm-keep mode: the step's agent is built once on the first
|
|
64
|
+
* invocation, cached under the step's identity, and reused on every
|
|
65
|
+
* subsequent invocation instead of being torn down per send. The
|
|
66
|
+
* agent's `close()` (the wrapped teardown that kills the LSP
|
|
67
|
+
* subprocess) runs only when the run-loop that owns the cache evicts
|
|
68
|
+
* it -- not in this adapter's `finally`.
|
|
69
|
+
*
|
|
70
|
+
* Supplying a cache is the explicit warm-keep signal: the run-loop
|
|
71
|
+
* builds and threads a cache only for the single-step long-lived
|
|
72
|
+
* deployment the deploy projection marked a warm candidate. Multi-step
|
|
73
|
+
* steps omit it and keep instantiate-send-teardown, so a multi-step
|
|
74
|
+
* agent is never warm-kept.
|
|
75
|
+
*/
|
|
76
|
+
warmCache?: WarmAgentCache;
|
|
77
|
+
/**
|
|
78
|
+
* Live per-step inference-source table the run-loop mutates in place on a
|
|
79
|
+
* rotation. Supplied only on the warm path: after building and storing the
|
|
80
|
+
* warm agent, the adapter re-applies the current table so a rotation that
|
|
81
|
+
* landed during the (async) first build -- which the empty-cache
|
|
82
|
+
* `applySources` no-op could not reach, and which the in-flight build had
|
|
83
|
+
* already captured the prior sources for -- is not lost for the warm
|
|
84
|
+
* agent's life.
|
|
85
|
+
*/
|
|
86
|
+
sourcesRef?: {
|
|
87
|
+
current: Record<string, InferenceSource[]>;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Run-boundary hook for the warm path (design §3c durability). When
|
|
91
|
+
* supplied, the adapter awaits it in the warm path's `finally` -- once
|
|
92
|
+
* per message, after the agent's send settles (whether it completed,
|
|
93
|
+
* aborted, or rejected). The sidecar wires this to flush the warm
|
|
94
|
+
* agent's conversation snapshot to the durable workflow-run substrate,
|
|
95
|
+
* so the conversation survives a child respawn between this message
|
|
96
|
+
* and the next. Awaited (not fire-and-forget) so a respawn landing
|
|
97
|
+
* immediately after the reply cannot lose this message's turns; a
|
|
98
|
+
* flush failure surfaces by rejecting the step rather than silently
|
|
99
|
+
* dropping the durability write.
|
|
100
|
+
*
|
|
101
|
+
* The `key` is the step identity (`authzContext.stepId`), the same key
|
|
102
|
+
* the warm cache uses, so the hook resolves the right per-agent
|
|
103
|
+
* durable store. Omitted on the cold path: a torn-down per-step agent
|
|
104
|
+
* has no cross-run conversation to mirror.
|
|
105
|
+
*/
|
|
106
|
+
onRunBoundary?: (key: string) => Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Construct the production `WorkflowRuntimeEnv.StepInvoker` adapter.
|
|
110
|
+
* The returned callable satisfies the runtime-env interface; the
|
|
111
|
+
* workflow-typed authorize, the per-step env builder, and the agent
|
|
112
|
+
* factory live in closure.
|
|
113
|
+
*/
|
|
114
|
+
export declare function createWorkflowStepInvoker(opts: WorkflowStepInvokerOpts): StepInvoker;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Production `WorkflowRuntimeEnv.StepInvoker` adapter.
|
|
2
|
+
//
|
|
3
|
+
// The runtime body sees the runtime-env shape: a callable that takes a
|
|
4
|
+
// `StepInvokeRequest` and resolves to a `StepInvokeResult`. This
|
|
5
|
+
// adapter translates each call into:
|
|
6
|
+
//
|
|
7
|
+
// 1. Build a `BaseEnv` for the per-step agent. The caller supplies a
|
|
8
|
+
// `buildEnv` callback that yields every required `BaseEnv` field
|
|
9
|
+
// except `authorize`; the adapter constructs the agent's
|
|
10
|
+
// `authorize` closure on top of the workflow-typed
|
|
11
|
+
// `WorkflowAuthorizeFn`, embedding the per-call `AuthorizeContext`
|
|
12
|
+
// so every authz call originating from the step carries the
|
|
13
|
+
// `{ stepId, attempt, runId }` triple the workflow runtime owns.
|
|
14
|
+
// 2. Instantiate the agent via `createAgent(def, env)`. The agent
|
|
15
|
+
// factory is wired through `opts.agentFactory` so tests can inject
|
|
16
|
+
// a stub agent that does not require a real inference source.
|
|
17
|
+
// 3. Synthesize an inbound message carrying the step's resolved
|
|
18
|
+
// `input` and deliver it through the agent's in-process send path
|
|
19
|
+
// (`agent.send`). `agent.send` is the in-process API for driving
|
|
20
|
+
// an agent without a transport; the call returns the assistant's
|
|
21
|
+
// reply once the reactor's `connector.reply` lands.
|
|
22
|
+
// 4. Capture the reply as the step's `output`. The output shape is
|
|
23
|
+
// `{ reply, turn }` so downstream consumers can either read the
|
|
24
|
+
// plain-text reply or walk the full assistant turn (tool calls,
|
|
25
|
+
// thinking blocks, etc.) without the step output dropping
|
|
26
|
+
// structure.
|
|
27
|
+
// 5. Tear down the agent (close + lock release) on every exit path,
|
|
28
|
+
// whether the step completed cleanly, the abort signal fired, or
|
|
29
|
+
// the underlying `agent.send` rejected.
|
|
30
|
+
//
|
|
31
|
+
// Abort handling: when `signal.aborted` fires mid-step, the adapter
|
|
32
|
+
// closes the agent (which drains the send queue with
|
|
33
|
+
// `AgentClosedError` and releases the workdir lock) and rejects the
|
|
34
|
+
// step with a `DOMException("aborted", "AbortError")`. A pre-aborted
|
|
35
|
+
// signal short-circuits without constructing an agent.
|
|
36
|
+
//
|
|
37
|
+
// Warm-keep mode (design §3b). When `opts.warmCache` is supplied the
|
|
38
|
+
// step is the sole step of a long-lived single-step deployment: the
|
|
39
|
+
// agent is built once on the first message, cached, and reused on every
|
|
40
|
+
// subsequent message rather than torn down per send. The warm path
|
|
41
|
+
// diverges from the per-step path at three points:
|
|
42
|
+
// - Construction: a cache hit reuses the warm agent (tools loaded,
|
|
43
|
+
// plugins live, LSP subprocess attached); only a miss builds one.
|
|
44
|
+
// - Abort: the step's abort signal is threaded into `agent.send` so a
|
|
45
|
+
// mid-conversation turn abort cancels just that turn -- the warm
|
|
46
|
+
// agent (and its LSP subprocess) stays alive and usable for the next
|
|
47
|
+
// message. The agent is NOT closed on abort; closing happens only at
|
|
48
|
+
// the run-loop's eviction points (shutdown/undeploy/recycle/drain
|
|
49
|
+
// teardown), which is the abort-one-turn vs teardown distinction.
|
|
50
|
+
// - Teardown: the `finally` does NOT close the agent and does NOT
|
|
51
|
+
// drain the event forwarder -- both span messages and are owned by
|
|
52
|
+
// the warm cache, torn down at eviction.
|
|
53
|
+
// Multi-step steps pass no cache and keep instantiate-send-teardown.
|
|
54
|
+
import { createAgent, } from "@intx/agent";
|
|
55
|
+
import { getLogger } from "@intx/log";
|
|
56
|
+
const logger = getLogger(["workflow-host", "step-invoker"]);
|
|
57
|
+
/**
|
|
58
|
+
* Construct the production `WorkflowRuntimeEnv.StepInvoker` adapter.
|
|
59
|
+
* The returned callable satisfies the runtime-env interface; the
|
|
60
|
+
* workflow-typed authorize, the per-step env builder, and the agent
|
|
61
|
+
* factory live in closure.
|
|
62
|
+
*/
|
|
63
|
+
export function createWorkflowStepInvoker(opts) {
|
|
64
|
+
const agentFactory = opts.agentFactory ?? createAgent;
|
|
65
|
+
return async (req) => invokeStep(opts, agentFactory, req);
|
|
66
|
+
}
|
|
67
|
+
async function invokeStep(opts, agentFactory, req) {
|
|
68
|
+
if (req.signal.aborted) {
|
|
69
|
+
// Short-circuit the pre-aborted case before the env builder runs.
|
|
70
|
+
// Building the env may allocate (workdir mkdir, isogit store
|
|
71
|
+
// construction); skipping that work when the caller already
|
|
72
|
+
// cancelled keeps the adapter from churning resources whose
|
|
73
|
+
// disposer we are about to invoke anyway.
|
|
74
|
+
throw abortError(req.signal);
|
|
75
|
+
}
|
|
76
|
+
if (opts.warmCache !== undefined) {
|
|
77
|
+
return invokeWarmStep(opts, opts.warmCache, agentFactory, req);
|
|
78
|
+
}
|
|
79
|
+
return invokeColdStep(opts, agentFactory, req);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Instantiate-send-teardown path (multi-step steps, and any deployment
|
|
83
|
+
* without a warm cache). The agent is built, sent one message, and torn
|
|
84
|
+
* down on every exit path. This is the original, unchanged behaviour.
|
|
85
|
+
*/
|
|
86
|
+
async function invokeColdStep(opts, agentFactory, req) {
|
|
87
|
+
const agent = await buildStepAgent(opts, agentFactory, req);
|
|
88
|
+
// Subscribe the agent's event stream BEFORE `agent.send` so the
|
|
89
|
+
// inbound `inference.start` and the per-turn / tool-call events are
|
|
90
|
+
// captured -- a subscription attached after send would miss the
|
|
91
|
+
// events emitted while the reactor processes the synthesized inbound
|
|
92
|
+
// message. The forwarder runs only when the caller supplied an
|
|
93
|
+
// `onEvent` sink; absent a sink the agent's `stream()` is never
|
|
94
|
+
// consumed (stub agents whose `stream()` throws stay untouched).
|
|
95
|
+
//
|
|
96
|
+
// `message.received` is the single intentional exclusion: it is an
|
|
97
|
+
// assembly-internal dequeue signal, and the hub-facing audit chain
|
|
98
|
+
// expresses per-message work through the `message.run.started` /
|
|
99
|
+
// `message.run.ended` bracket pair instead. The filter is an
|
|
100
|
+
// allowlist-of-everything-except, so new `InferenceEvent` members
|
|
101
|
+
// flow through by default.
|
|
102
|
+
const eventForward = subscribeAgentEvents(agent, opts.onEvent);
|
|
103
|
+
try {
|
|
104
|
+
const sendResult = await sendWithAbort(agent, req, { closeOnAbort: true });
|
|
105
|
+
return { output: { reply: sendResult.reply, turn: sendResult.turn } };
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
// `close` is idempotent: a second call after the send already
|
|
109
|
+
// resolved still releases the workdir lock and tears down stream
|
|
110
|
+
// consumers. We await so the lock is gone before the adapter
|
|
111
|
+
// returns -- a subsequent step on the same workdir must not race
|
|
112
|
+
// a still-closing agent.
|
|
113
|
+
await agent.close();
|
|
114
|
+
// `agent.close()` terminates every active `stream()` iterator, so
|
|
115
|
+
// the forwarder's for-await loop has ended (or is about to). Await
|
|
116
|
+
// it after close so the subscription is fully drained before the
|
|
117
|
+
// adapter returns and no listener outlives the step. Awaited last
|
|
118
|
+
// because the loop only ends once close has fired.
|
|
119
|
+
await eventForward;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Warm-keep path (design §3b). The agent is built once on the first
|
|
124
|
+
* invocation and cached under the step's identity; every later
|
|
125
|
+
* invocation reuses it. The cache owns the agent's lifetime: this
|
|
126
|
+
* adapter neither closes the agent nor drains its event forwarder on
|
|
127
|
+
* exit -- both span messages and are torn down by the run-loop at an
|
|
128
|
+
* eviction point.
|
|
129
|
+
*
|
|
130
|
+
* The agent's single lifetime stream is forwarded through a mutable
|
|
131
|
+
* per-entry event sink the cache holds. This invocation points the sink
|
|
132
|
+
* at THIS step's `onEvent` before `agent.send` and clears it after, so
|
|
133
|
+
* each run's events reach its own channel and a stray event between
|
|
134
|
+
* messages is dropped rather than delivered to a torn-down channel.
|
|
135
|
+
*
|
|
136
|
+
* A mid-conversation abort cancels only the in-flight turn (the abort
|
|
137
|
+
* signal is threaded into `agent.send`); the warm agent and its LSP
|
|
138
|
+
* subprocess survive for the next message. The agent is closed only at
|
|
139
|
+
* eviction -- the abort-one-turn vs teardown distinction.
|
|
140
|
+
*/
|
|
141
|
+
async function invokeWarmStep(opts, warmCache, agentFactory, req) {
|
|
142
|
+
const key = req.authzContext.stepId;
|
|
143
|
+
if (key === undefined) {
|
|
144
|
+
// The warm cache is keyed by the step's identity; the workflow
|
|
145
|
+
// runtime threads `stepId` through every step's `AuthorizeContext`,
|
|
146
|
+
// so an absent id is a runtime-wiring bug. Fail loudly rather than
|
|
147
|
+
// warm-keep agents under an ambiguous key that would collide
|
|
148
|
+
// distinct steps onto one cached agent.
|
|
149
|
+
throw new Error("workflow step invoker: warm-keep requires authzContext.stepId; the runtime must thread the step id through every invocation");
|
|
150
|
+
}
|
|
151
|
+
let agent = warmCache.acquire(key);
|
|
152
|
+
if (agent === null) {
|
|
153
|
+
// Lazy first-message build. The agent's stream is consumed once,
|
|
154
|
+
// for its whole life, through the entry's mutable sink ref; the
|
|
155
|
+
// forwarder loop ends only when the agent closes at eviction.
|
|
156
|
+
agent = await buildStepAgent(opts, agentFactory, req);
|
|
157
|
+
const eventSinkRef = { current: null };
|
|
158
|
+
const eventForward = subscribeAgentEvents(agent, (event) => {
|
|
159
|
+
const sink = eventSinkRef.current;
|
|
160
|
+
if (sink !== null)
|
|
161
|
+
sink(event);
|
|
162
|
+
});
|
|
163
|
+
warmCache.store(key, agent, eventSinkRef, eventForward);
|
|
164
|
+
// Re-apply the live source table to the just-built agent. A rotation
|
|
165
|
+
// that arrived during the (async) build hit the still-empty cache as a
|
|
166
|
+
// no-op `applySources` while the build had already captured the prior
|
|
167
|
+
// sources; now that the entry exists, this applies any such rotation so
|
|
168
|
+
// it is not lost for the warm agent's life. No-op when the table is
|
|
169
|
+
// unchanged. The wire boundary guarantees element 0 is the default.
|
|
170
|
+
const live = opts.sourcesRef?.current[key];
|
|
171
|
+
const head = live?.[0];
|
|
172
|
+
if (live !== undefined && head !== undefined) {
|
|
173
|
+
warmCache.applySources(live, head.id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (opts.onEvent !== undefined) {
|
|
177
|
+
warmCache.setEventSink(key, opts.onEvent);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const sendResult = await sendWithAbort(agent, req, { closeOnAbort: false });
|
|
181
|
+
return { output: { reply: sendResult.reply, turn: sendResult.turn } };
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
// Do NOT close the agent or drain its forwarder: both span
|
|
185
|
+
// messages and are owned by the warm cache, torn down at eviction.
|
|
186
|
+
// Clear the per-message sink so an event emitted between this send
|
|
187
|
+
// and the next is dropped rather than delivered to this run's
|
|
188
|
+
// torn-down per-run channel.
|
|
189
|
+
warmCache.clearEventSink(key);
|
|
190
|
+
// Run-boundary durability flush (design §3c). Mirror the warm
|
|
191
|
+
// agent's conversation snapshot to the durable substrate once per
|
|
192
|
+
// message, after the send settles, so a respawn before the next
|
|
193
|
+
// message resumes from this message's turns. Awaited so the
|
|
194
|
+
// durability write completes (or surfaces its failure) before the
|
|
195
|
+
// step result is observed.
|
|
196
|
+
if (opts.onRunBoundary !== undefined) {
|
|
197
|
+
await opts.onRunBoundary(key);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build the per-step agent: assemble the `BaseEnv`, wrap the
|
|
203
|
+
* workflow-typed authorize into the agent harness's `AuthorizeFn`, and
|
|
204
|
+
* instantiate the agent through the factory. Shared by the cold path and
|
|
205
|
+
* the warm path's first-message build.
|
|
206
|
+
*/
|
|
207
|
+
async function buildStepAgent(opts, agentFactory, req) {
|
|
208
|
+
const envBase = await opts.buildEnv(req);
|
|
209
|
+
const authorize = wrapAuthorize(opts.workflowAuthorize, req.authzContext);
|
|
210
|
+
const env = { ...envBase, authorize };
|
|
211
|
+
return agentFactory(req.agent, env);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Drive one `agent.send`, racing it against the step's abort signal.
|
|
215
|
+
*
|
|
216
|
+
* `closeOnAbort` selects the abort semantics:
|
|
217
|
+
* - `true` (cold path): the in-flight send is left to settle via
|
|
218
|
+
* `agent.close()` in the caller's `finally`, which aborts the
|
|
219
|
+
* reactor and drains the send queue with `AgentClosedError`. We do
|
|
220
|
+
* not thread the signal into `agent.send`; the abort attribution is
|
|
221
|
+
* the `DOMException` rejected here, and close tears the agent down.
|
|
222
|
+
* - `false` (warm path): the abort signal is threaded into
|
|
223
|
+
* `agent.send`, so a mid-turn abort cancels only this turn. The warm
|
|
224
|
+
* agent stays alive for the next message; no `agent.close()` runs.
|
|
225
|
+
*
|
|
226
|
+
* In both modes a pre-send abort (the signal already aborted when the
|
|
227
|
+
* executor runs) and a mid-send abort reject with the abort error so the
|
|
228
|
+
* step's abort attribution wins regardless of which side settles first.
|
|
229
|
+
*/
|
|
230
|
+
async function sendWithAbort(agent, req, cfg) {
|
|
231
|
+
let abortListener = null;
|
|
232
|
+
try {
|
|
233
|
+
return await new Promise((resolve, reject) => {
|
|
234
|
+
// Re-check the abort signal inside the executor. `buildEnv` and
|
|
235
|
+
// `agentFactory` (or a warm-cache acquire) yield to the
|
|
236
|
+
// microtask queue, and the caller can fire `signal.abort()`
|
|
237
|
+
// between the entry-time check and here. Without this re-check,
|
|
238
|
+
// a mid-construction abort would attach the listener to an
|
|
239
|
+
// already-aborted signal that never fires the event again, and
|
|
240
|
+
// the send would hang to the workflow runtime's step timeout.
|
|
241
|
+
if (req.signal.aborted) {
|
|
242
|
+
reject(abortError(req.signal));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const onAbort = () => {
|
|
246
|
+
// The abort signal racing the send. On the cold path the
|
|
247
|
+
// caller's `finally` close aborts the reactor and the
|
|
248
|
+
// in-flight `agent.send` rejects shortly after; on the warm
|
|
249
|
+
// path the signal threaded into `agent.send` rejects the
|
|
250
|
+
// send. Either way we reject here so the abort attribution
|
|
251
|
+
// wins regardless of which side settles first.
|
|
252
|
+
reject(abortError(req.signal));
|
|
253
|
+
};
|
|
254
|
+
abortListener = onAbort;
|
|
255
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
256
|
+
let synthesized;
|
|
257
|
+
try {
|
|
258
|
+
synthesized = synthesizeInputContent(req.input);
|
|
259
|
+
}
|
|
260
|
+
catch (cause) {
|
|
261
|
+
reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const sendOpts = cfg.closeOnAbort ? undefined : { signal: req.signal };
|
|
265
|
+
agent.send(synthesized, sendOpts).then(resolve, (cause) => {
|
|
266
|
+
reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
if (abortListener !== null) {
|
|
272
|
+
req.signal.removeEventListener("abort", abortListener);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Subscribe the per-step agent's event stream and forward every
|
|
278
|
+
* `InferenceEvent` to `onEvent`. Returns a promise that settles when
|
|
279
|
+
* the forwarder's loop ends -- which happens when `agent.close()`
|
|
280
|
+
* terminates the stream iterator. When `onEvent` is absent the agent's
|
|
281
|
+
* `stream()` is never consumed and the returned promise resolves
|
|
282
|
+
* immediately, so a caller that does not want observability never
|
|
283
|
+
* touches the stream (stub agents whose `stream()` throws stay
|
|
284
|
+
* untouched).
|
|
285
|
+
*
|
|
286
|
+
* Forwarding is best-effort observability: a sink that throws is
|
|
287
|
+
* logged and swallowed so a downstream consumer's failure cannot abort
|
|
288
|
+
* the step. A failure of the stream iterator itself (the agent's
|
|
289
|
+
* teardown surfacing through the iterator) is logged at warn.
|
|
290
|
+
*/
|
|
291
|
+
function subscribeAgentEvents(agent, onEvent) {
|
|
292
|
+
if (onEvent === undefined) {
|
|
293
|
+
return Promise.resolve();
|
|
294
|
+
}
|
|
295
|
+
const events = agent.stream();
|
|
296
|
+
return (async () => {
|
|
297
|
+
try {
|
|
298
|
+
for await (const event of events) {
|
|
299
|
+
if (event.type === "message.received")
|
|
300
|
+
continue;
|
|
301
|
+
try {
|
|
302
|
+
onEvent(event);
|
|
303
|
+
}
|
|
304
|
+
catch (cause) {
|
|
305
|
+
logger.error `step-invoker event sink threw forwarding ${event.type}: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (cause) {
|
|
310
|
+
logger.warn `step-invoker event forwarder terminated: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Construct the agent harness's `AuthorizeFn` from the workflow-typed
|
|
316
|
+
* callback. The returned closure ignores its third positional argument
|
|
317
|
+
* (the agent layer's generic context slot, typed `unknown`) and
|
|
318
|
+
* delegates to the workflow-typed authorize with the per-step
|
|
319
|
+
* `AuthorizeContext` captured at closure-build time. This is the same
|
|
320
|
+
* shape the in-memory `runlocal` step invoker uses; surfacing the
|
|
321
|
+
* conversion here keeps the agent layer workflow-unaware.
|
|
322
|
+
*/
|
|
323
|
+
function wrapAuthorize(workflowAuthorize, authzContext) {
|
|
324
|
+
return async (resource, action) => workflowAuthorize(resource, action, authzContext);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Encode the step's resolved `input` as the synthetic inbound message
|
|
328
|
+
* content. The workflow runtime resolves `input` from the step's input
|
|
329
|
+
* selector and hands it to the invoker as `unknown`; `agent.send`
|
|
330
|
+
* expects a string or an `InboundMessage`. JSON-stringify covers the
|
|
331
|
+
* common case (objects, arrays, primitives) and round-trips through
|
|
332
|
+
* the agent's synthetic mail boundary verbatim.
|
|
333
|
+
*
|
|
334
|
+
* Inputs that JSON.stringify cannot serialize (functions, symbols, raw
|
|
335
|
+
* `undefined`) are surfaced as a thrown error rather than a silent
|
|
336
|
+
* `"undefined"` string -- step outputs that depend on the input shape
|
|
337
|
+
* deserve a loud failure if the workflow-defined selector produced a
|
|
338
|
+
* non-serializable value.
|
|
339
|
+
*/
|
|
340
|
+
function synthesizeInputContent(input) {
|
|
341
|
+
if (typeof input === "string")
|
|
342
|
+
return input;
|
|
343
|
+
const encoded = JSON.stringify(input);
|
|
344
|
+
if (encoded === undefined) {
|
|
345
|
+
throw new Error(`workflow step invoker: input of typeof ${typeof input} is not JSON-serializable; the step's input selector must resolve to a serializable value`);
|
|
346
|
+
}
|
|
347
|
+
return encoded;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Construct the rejection used when `signal.aborted` short-circuits or
|
|
351
|
+
* fires mid-step. Mirrors the DOMException-shaped abort errors the
|
|
352
|
+
* inference harness emits so consumers can `instanceof DOMException` /
|
|
353
|
+
* `name === "AbortError"` against a stable shape across the runtime.
|
|
354
|
+
*/
|
|
355
|
+
function abortError(signal) {
|
|
356
|
+
const reason = signal.reason;
|
|
357
|
+
if (reason instanceof Error)
|
|
358
|
+
return reason;
|
|
359
|
+
return new DOMException("aborted", "AbortError");
|
|
360
|
+
}
|