@automatalabs/acp-agents 0.1.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/runner.js ADDED
@@ -0,0 +1,211 @@
1
+ // AcpAgentRunner — the AgentRunner seam implementation (the LEAF the engine injects against).
2
+ // One method, two backend strategies behind it. Per run():
3
+ // 1. pick the backend by model/tier (cross-provider routing = which ACP server to spawn)
4
+ // 2. ACQUIRE a pooled connection + session/new { cwd } (per-session cwd = worktree isolation;
5
+ // the PROCESS is pool-managed and REUSED across runs — never spawned/killed per run)
6
+ // 3. select the model via session/set_config_option (onModelResolved / onModelFallback)
7
+ // 4. apply the schema per backend (Claude: at session/new; Codex: per-turn _meta)
8
+ // 5. prompt + drain; enforce the tool allow/deny policy via permission auto-responses
9
+ // 6. schema -> native -> validate -> re-prompt ladder -> SCHEMA_NONCOMPLIANCE
10
+ // no schema -> final assistant text (empty -> AGENT_EMPTY_OUTPUT, recoverable)
11
+ // provider wall (thrown) -> PROVIDER_USAGE_LIMIT (non-recoverable, resetHint)
12
+ // pooled process crash (thrown) -> recoverable AGENT_EXECUTION_ERROR (engine retries on a
13
+ // fresh process; the dead connection is evicted from the pool)
14
+ // 7. usage -> onUsage on BOTH the success and error paths; honor opts.signal (-> session/cancel)
15
+ // 8. RELEASE the session (session/close) WITHOUT killing the process; return it to the pool
16
+ //
17
+ // Timeout and abort are the ENGINE's job: we honor opts.signal (wired to ACP session/cancel)
18
+ // and re-throw on abort, but never implement our own timeout.
19
+ import { WorkflowError, WorkflowErrorCode, } from "@automatalabs/shared-types";
20
+ import { AcpAgentPool } from "./pool.js";
21
+ import { ClaudeBackend } from "./backends/claude.js";
22
+ import { CodexBackend } from "./backends/codex.js";
23
+ import { mapThrownError } from "./errors-map.js";
24
+ import { resolveStructuredOutput } from "./structured-output.js";
25
+ export class AcpAgentRunner {
26
+ pool;
27
+ constructor(options = {}) {
28
+ this.pool = new AcpAgentPool(options);
29
+ }
30
+ async run(prompt, options = {}) {
31
+ const opts = options;
32
+ const schema = opts.schema;
33
+ const backend = selectBackend(opts);
34
+ const policy = { allow: opts.toolNames, deny: opts.disallowedToolNames };
35
+ const cwd = opts.cwd ?? process.cwd();
36
+ const session = await this.pool.acquire(backend, {
37
+ cwd,
38
+ schema,
39
+ policy,
40
+ signal: opts.signal,
41
+ mcpServers: opts.mcpServers,
42
+ // Engine correlation id -> session/new _meta (META_KEYS.runId). Additive; never hashed.
43
+ runId: opts.runId,
44
+ });
45
+ try {
46
+ opts.signal?.throwIfAborted();
47
+ await applyModelSelection(session, opts);
48
+ const text = buildPrompt(prompt, opts, Boolean(schema));
49
+ const promptMeta = backend.promptMeta(schema);
50
+ const response = await session.prompt(text, promptMeta);
51
+ opts.signal?.throwIfAborted();
52
+ // Inspect the turn's stop reason BEFORE the text/schema path: a refusal or truncation
53
+ // must surface distinctly here, never be misread as empty output or burned through the
54
+ // schema-repair ladder into SCHEMA_NONCOMPLIANCE.
55
+ assertNormalStopReason(response.stopReason, opts.label);
56
+ if (schema) {
57
+ const structuredSession = {
58
+ prompt: async (repromptText) => {
59
+ const repromptResponse = await session.prompt(repromptText, promptMeta);
60
+ // A repair turn that refuses / truncates / cancels must also surface distinctly
61
+ // instead of silently continuing the ladder.
62
+ assertNormalStopReason(repromptResponse.stopReason, opts.label);
63
+ },
64
+ lastText: () => session.currentTurnText(),
65
+ tryNative: () => backend.nativeStructured(session),
66
+ };
67
+ const result = await resolveStructuredOutput(structuredSession, schema, {
68
+ maxSchemaRetries: opts.maxSchemaRetries,
69
+ signal: opts.signal,
70
+ label: opts.label,
71
+ });
72
+ return result;
73
+ }
74
+ const finalText = session.currentTurnText().trim();
75
+ if (!finalText) {
76
+ throw new WorkflowError("Subagent produced no assistant output", WorkflowErrorCode.AGENT_EMPTY_OUTPUT, {
77
+ recoverable: true,
78
+ agentLabel: opts.label,
79
+ });
80
+ }
81
+ return finalText;
82
+ }
83
+ catch (error) {
84
+ // Abort is the engine's concern (throwIfAborted before/after the call) — re-throw it raw.
85
+ if (opts.signal?.aborted)
86
+ throw error;
87
+ throw mapThrownError(error, opts.label);
88
+ }
89
+ finally {
90
+ // Read real usage on BOTH success and error so partial usage is never lost.
91
+ try {
92
+ opts.onUsage?.(session.usage.toAgentUsage());
93
+ }
94
+ catch {
95
+ // usage is best-effort; never let it mask the real result/error.
96
+ }
97
+ try {
98
+ opts.onHistory?.(session.history);
99
+ }
100
+ catch {
101
+ // history is diagnostic only.
102
+ }
103
+ // Release the SESSION (best-effort session/close) WITHOUT killing the pooled process.
104
+ try {
105
+ await session.release();
106
+ }
107
+ catch {
108
+ // release is best-effort (session already untracked); never mask the real result/error.
109
+ }
110
+ }
111
+ }
112
+ /** Tear down the whole pool (close every long-lived process). Call when the run ends / the
113
+ * runner is disposed. Beyond the AgentRunner seam (additive) — never enters the resume hash. */
114
+ async dispose() {
115
+ await this.pool.dispose();
116
+ }
117
+ }
118
+ /** Factory the mcp-server composition root calls to inject the runner into the engine. The pool
119
+ * size is a runner-level option (default 1, else AGENTPRISM_ACP_POOL_SIZE) — NOT a RunOptions
120
+ * field, so it never enters hashAgentCall / the resume identity. */
121
+ export function createAcpRunner(options) {
122
+ return new AcpAgentRunner(options);
123
+ }
124
+ /**
125
+ * Map a PromptResponse.stopReason onto the seam's error contract. `end_turn` (and any
126
+ * unknown future reason) is a normal completion — fall through to the text/schema path.
127
+ * The abnormal reasons each get a DISTINCT, non-recoverable failure so the engine never
128
+ * retries a refused/truncated prompt (burning the retry budget) and never mistakes it for
129
+ * recoverable empty output:
130
+ * - refusal -> AGENT_EXECUTION_ERROR "model refused to respond"
131
+ * - max_tokens / max_turn_requests -> AGENT_EXECUTION_ERROR "output truncated"
132
+ * - cancelled -> WORKFLOW_ABORTED
133
+ */
134
+ function assertNormalStopReason(stopReason, label) {
135
+ switch (stopReason) {
136
+ case "refusal":
137
+ throw new WorkflowError("model refused to respond", WorkflowErrorCode.AGENT_EXECUTION_ERROR, {
138
+ recoverable: false,
139
+ agentLabel: label,
140
+ });
141
+ case "max_tokens":
142
+ case "max_turn_requests":
143
+ throw new WorkflowError(`output truncated (stop reason: ${stopReason})`, WorkflowErrorCode.AGENT_EXECUTION_ERROR, { recoverable: false, agentLabel: label });
144
+ case "cancelled":
145
+ throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, {
146
+ recoverable: false,
147
+ agentLabel: label,
148
+ });
149
+ default:
150
+ // "end_turn" and any unrecognized future reason: normal completion.
151
+ return;
152
+ }
153
+ }
154
+ async function applyModelSelection(session, opts) {
155
+ // `model` wins; `tier` is consulted only when `model` is unset (frozen contract).
156
+ const spec = opts.model ?? opts.tier;
157
+ if (!spec)
158
+ return;
159
+ const { matched, resolved, modifierFallbacks } = await session.selectModel(spec);
160
+ if (matched)
161
+ opts.onModelResolved?.(resolved ?? spec);
162
+ else
163
+ opts.onModelFallback?.(spec);
164
+ // Symmetric to model fallback: a requested reasoning_effort / Fast-mode value the catalog
165
+ // does not advertise is a silent no-op in the session. Surface it on the SAME channel so
166
+ // incorrect tiering is observable (best-effort — reported, never thrown).
167
+ for (const fallback of modifierFallbacks ?? [])
168
+ opts.onModelFallback?.(fallback);
169
+ }
170
+ function buildPrompt(prompt, opts, structured) {
171
+ const parts = [];
172
+ if (opts.instructions)
173
+ parts.push(opts.instructions);
174
+ if (opts.label)
175
+ parts.push(`Task label: ${opts.label}`);
176
+ parts.push(prompt);
177
+ if (structured) {
178
+ parts.push([
179
+ "Final output contract:",
180
+ "- Your FINAL message MUST be a single JSON object that conforms to the required output schema.",
181
+ "- Output ONLY that JSON object — no prose, no explanation, and no markdown code fences.",
182
+ "- If you need to inspect files or run commands first, do so, then emit the JSON object as your final message.",
183
+ ].join("\n"));
184
+ }
185
+ return parts.join("\n\n");
186
+ }
187
+ /** Pick the backend by model/tier. Cross-provider routing = which ACP server to spawn. */
188
+ export function selectBackend(opts) {
189
+ const id = backendIdForSpec(opts.model) ?? backendIdForSpec(opts.tier) ?? defaultBackendId();
190
+ return id === "codex" ? new CodexBackend() : new ClaudeBackend();
191
+ }
192
+ function backendIdForSpec(spec) {
193
+ if (!spec)
194
+ return undefined;
195
+ const lower = spec.toLowerCase();
196
+ const slash = lower.indexOf("/");
197
+ const provider = slash > 0 ? lower.slice(0, slash) : "";
198
+ if (provider === "openai" || provider === "codex")
199
+ return "codex";
200
+ if (provider === "anthropic" || provider === "claude")
201
+ return "claude";
202
+ const id = slash > 0 ? lower.slice(slash + 1) : lower;
203
+ if (/codex|gpt|openai|\bo\d/.test(id))
204
+ return "codex";
205
+ if (/claude|opus|sonnet|haiku|anthropic/.test(id))
206
+ return "claude";
207
+ return undefined;
208
+ }
209
+ function defaultBackendId() {
210
+ return process.env.AGENTPRISM_DEFAULT_BACKEND?.toLowerCase() === "codex" ? "codex" : "claude";
211
+ }
@@ -0,0 +1,11 @@
1
+ import type { TSchema } from "typebox";
2
+ /** Deep-clone a typebox schema into a plain JSON Schema object (Claude `outputFormat.schema`). */
3
+ export declare function toJsonSchema(schema: TSchema): Record<string, unknown>;
4
+ /**
5
+ * Deep-clone a typebox schema and normalize it to OpenAI STRICT rules (Codex
6
+ * `turn/start.outputSchema`): every property required, additionalProperties:false,
7
+ * unsupported validation keywords stripped. Operates on the clone only — the schema that
8
+ * feeds hashAgentCall is never mutated.
9
+ */
10
+ export declare function toStrictJsonSchema(schema: TSchema): Record<string, unknown>;
11
+ //# sourceMappingURL=schema-strict.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-strict.d.ts","sourceRoot":"","sources":["../src/schema-strict.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,kGAAkG;AAClG,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAErE;AA6LD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG3E"}
@@ -0,0 +1,189 @@
1
+ /** Deep-clone a typebox schema into a plain JSON Schema object (Claude `outputFormat.schema`). */
2
+ export function toJsonSchema(schema) {
3
+ return JSON.parse(JSON.stringify(schema));
4
+ }
5
+ // JSON-Schema keywords OpenAI strict structured output does NOT accept. Stripped on the
6
+ // COPY before sending to Codex (turn/start.outputSchema is sent in strict mode). Structural
7
+ // keywords (type/properties/items/enum/anyOf/oneOf/allOf/$ref/$defs/definitions/const/
8
+ // description/required/additionalProperties) are preserved.
9
+ const STRICT_UNSUPPORTED_KEYWORDS = new Set([
10
+ "$schema",
11
+ "$id",
12
+ "title",
13
+ "default",
14
+ "examples",
15
+ "format",
16
+ "pattern",
17
+ "minLength",
18
+ "maxLength",
19
+ "minimum",
20
+ "maximum",
21
+ "exclusiveMinimum",
22
+ "exclusiveMaximum",
23
+ "multipleOf",
24
+ "minItems",
25
+ "maxItems",
26
+ "uniqueItems",
27
+ "minContains",
28
+ "maxContains",
29
+ "minProperties",
30
+ "maxProperties",
31
+ "contentEncoding",
32
+ "contentMediaType",
33
+ "patternProperties",
34
+ "additionalItems",
35
+ "unevaluatedProperties",
36
+ "unevaluatedItems",
37
+ "dependencies",
38
+ "dependentRequired",
39
+ "dependentSchemas",
40
+ "propertyNames",
41
+ "readOnly",
42
+ "writeOnly",
43
+ "deprecated",
44
+ ]);
45
+ function normalizeStrictNode(node) {
46
+ if (Array.isArray(node))
47
+ return node.map(normalizeStrictNode);
48
+ if (node === null || typeof node !== "object")
49
+ return node;
50
+ // OpenAI strict rejects `allOf` (composition). Flatten the trivial object-merge case;
51
+ // anything non-trivial throws a clear schema error rather than silently shipping invalid
52
+ // strict schema. Flattening happens BEFORE keyword processing so the merged node then
53
+ // takes the normal object-schema path below.
54
+ let input = node;
55
+ if (input["allOf"] !== undefined) {
56
+ input = flattenAllOf(input);
57
+ }
58
+ const out = {};
59
+ for (const [key, value] of Object.entries(input)) {
60
+ if (STRICT_UNSUPPORTED_KEYWORDS.has(key))
61
+ continue;
62
+ // OpenAI strict accepts `anyOf` but not `oneOf` — map it over (its branches are an
63
+ // exclusive subset of anyOf, so this only widens acceptance, never the validated set
64
+ // beyond what the client-side typebox Check re-narrows).
65
+ const targetKey = key === "oneOf" ? "anyOf" : key;
66
+ out[targetKey] = normalizeStrictNode(value);
67
+ }
68
+ // OpenAI strict rule: every object must set additionalProperties:false and list EVERY
69
+ // property in `required`. Treat a node as an object schema when it declares properties
70
+ // (type may be "object" or omitted by some generators).
71
+ const properties = out["properties"];
72
+ const isObjectSchema = out["type"] === "object" || (out["type"] === undefined && properties !== undefined);
73
+ if (isObjectSchema && properties !== null && typeof properties === "object") {
74
+ const propObj = properties;
75
+ const keys = Object.keys(propObj);
76
+ // Forcing all-required would make originally-OPTIONAL props un-omittable. Keep them
77
+ // expressible by unioning their type with "null" — the model may emit null for "absent".
78
+ // Optionality is read from the node's ORIGINAL `required` (before we overwrite it).
79
+ const originallyRequired = new Set(toStringArray(input["required"]));
80
+ for (const key of keys) {
81
+ if (!originallyRequired.has(key))
82
+ propObj[key] = makeNullable(propObj[key]);
83
+ }
84
+ out["additionalProperties"] = false;
85
+ out["required"] = keys;
86
+ }
87
+ return out;
88
+ }
89
+ /** Pick out the string members of a JSON-Schema `required` array (tolerating junk). */
90
+ function toStringArray(value) {
91
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
92
+ }
93
+ /**
94
+ * Make a (already strict-normalized) property schema accept `null`, so an originally-optional
95
+ * field stays expressible once strict mode forces it into `required`:
96
+ * - a union (`anyOf`): add a `{ type: "null" }` branch if absent;
97
+ * - a string `type`: widen to `[type, "null"]`;
98
+ * - an array `type`: append "null" if absent;
99
+ * - anything else (a bare `$ref`, an empty schema, etc.): wrap in `anyOf:[schema,{null}]`.
100
+ */
101
+ function makeNullable(schema) {
102
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema))
103
+ return schema;
104
+ const node = schema;
105
+ if (Array.isArray(node["anyOf"])) {
106
+ const branches = node["anyOf"];
107
+ if (!branches.some(isNullTypeSchema))
108
+ branches.push({ type: "null" });
109
+ return node;
110
+ }
111
+ const type = node["type"];
112
+ if (typeof type === "string") {
113
+ if (type !== "null")
114
+ node["type"] = [type, "null"];
115
+ return node;
116
+ }
117
+ if (Array.isArray(type)) {
118
+ if (!type.includes("null"))
119
+ type.push("null");
120
+ return node;
121
+ }
122
+ // No `type` and no `anyOf` to widen (e.g. a bare `$ref`): wrap it in a nullable union.
123
+ return { anyOf: [node, { type: "null" }] };
124
+ }
125
+ function isNullTypeSchema(node) {
126
+ return (node !== null && typeof node === "object" && node["type"] === "null");
127
+ }
128
+ /**
129
+ * Flatten an `allOf` node into a single object schema for OpenAI strict mode (which rejects
130
+ * `allOf`). Only the TRIVIAL case is supported: every member (plus any sibling keywords on the
131
+ * node itself) is an object schema, so their `properties`/`required` merge into one object.
132
+ * Any member that is a non-object schema (a scalar/array constraint, a `$ref`, etc.) cannot be
133
+ * trivially merged and throws a clear error — the caller must rewrite the schema without
134
+ * `allOf` for strict structured output.
135
+ */
136
+ function flattenAllOf(node) {
137
+ const allOf = node["allOf"];
138
+ if (!Array.isArray(allOf)) {
139
+ throw new Error('OpenAI strict schema: "allOf" must be an array to be flattened.');
140
+ }
141
+ const { allOf: _drop, ...siblings } = node;
142
+ const parts = [siblings, ...allOf];
143
+ const mergedProps = {};
144
+ const mergedRequired = [];
145
+ const merged = {};
146
+ let sawProperties = false;
147
+ for (const part of parts) {
148
+ if (part === null || typeof part !== "object" || Array.isArray(part)) {
149
+ throw new Error('OpenAI strict schema: cannot flatten an "allOf" whose member is not an object schema.');
150
+ }
151
+ let p = part;
152
+ if (p["allOf"] !== undefined)
153
+ p = flattenAllOf(p); // recurse into nested allOf first
154
+ const props = p["properties"];
155
+ const hasProps = props !== null && typeof props === "object" && !Array.isArray(props);
156
+ const type = p["type"];
157
+ const objectLike = hasProps || type === "object" || type === undefined;
158
+ if (!objectLike) {
159
+ throw new Error(`OpenAI strict schema: cannot flatten "allOf" containing a non-object subschema (type: ${JSON.stringify(type)}); rewrite the schema without allOf for strict structured output.`);
160
+ }
161
+ if (hasProps) {
162
+ sawProperties = true;
163
+ Object.assign(mergedProps, props);
164
+ }
165
+ mergedRequired.push(...toStringArray(p["required"]));
166
+ for (const [key, value] of Object.entries(p)) {
167
+ if (key === "properties" || key === "required" || key === "type")
168
+ continue;
169
+ merged[key] = value;
170
+ }
171
+ }
172
+ if (!sawProperties) {
173
+ throw new Error('OpenAI strict schema: "allOf" had no object subschemas to flatten.');
174
+ }
175
+ merged["type"] = "object";
176
+ merged["properties"] = mergedProps;
177
+ merged["required"] = [...new Set(mergedRequired)];
178
+ return merged;
179
+ }
180
+ /**
181
+ * Deep-clone a typebox schema and normalize it to OpenAI STRICT rules (Codex
182
+ * `turn/start.outputSchema`): every property required, additionalProperties:false,
183
+ * unsupported validation keywords stripped. Operates on the clone only — the schema that
184
+ * feeds hashAgentCall is never mutated.
185
+ */
186
+ export function toStrictJsonSchema(schema) {
187
+ const clone = JSON.parse(JSON.stringify(schema));
188
+ return normalizeStrictNode(clone);
189
+ }
@@ -0,0 +1,37 @@
1
+ import type { TSchema } from "typebox";
2
+ /**
3
+ * Find a JSON object/array in free-form text: a fenced ```json block if present, else the
4
+ * first balanced {...} or [...]. Best-effort (the schema check is the real gate). Returns the
5
+ * raw JSON string, or undefined when none is found. (Ported VERBATIM from pi agent.ts.)
6
+ */
7
+ export declare function findJsonBlock(text: string): string | undefined;
8
+ /** Coerce an already-parsed value toward the schema and accept it only if it then validates. */
9
+ export declare function validateValue(value: unknown, schema: TSchema): unknown;
10
+ /**
11
+ * Last-resort structured-output recovery: extract a JSON block from prose, coerce it toward
12
+ * the schema, and accept it only if it then validates. Never fabricates — returns undefined
13
+ * unless the parsed value genuinely satisfies the schema. (Ported VERBATIM from pi agent.ts.)
14
+ */
15
+ export declare function extractValidated(text: string, schema: TSchema): unknown;
16
+ /** Minimal session surface the structured-output ladder needs (real ACP session or a test double). */
17
+ export interface StructuredSession {
18
+ /** Send one more (re-prompt) turn and drain it. */
19
+ prompt(text: string): Promise<void>;
20
+ /** The latest turn's assistant text (for prose extraction). */
21
+ lastText(): string;
22
+ /** The backend's native structured result for the latest turn (unvalidated), or undefined. */
23
+ tryNative(): unknown;
24
+ }
25
+ export interface ResolveOptions {
26
+ /** Extra repair turns before giving up. Leaf default 2 (matches pi). */
27
+ maxSchemaRetries?: number;
28
+ signal?: AbortSignal;
29
+ label?: string;
30
+ }
31
+ /**
32
+ * Resolve a schema agent's result via the ladder above. Returns the validated value (typed
33
+ * unknown at this layer; the caller casts to the seam's AgentResult<S>). Throws
34
+ * SCHEMA_NONCOMPLIANCE (non-recoverable) when no valid value is produced after the retries.
35
+ */
36
+ export declare function resolveStructuredOutput(session: StructuredSession, schema: TSchema, options: ResolveOptions): Promise<unknown>;
37
+ //# sourceMappingURL=structured-output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structured-output.d.ts","sourceRoot":"","sources":["../src/structured-output.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIvC;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAa9D;AAED,gGAAgG;AAChG,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAQtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAUvE;AAED,sGAAsG;AACtG,MAAM,WAAW,iBAAiB;IAChC,mDAAmD;IACnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,+DAA+D;IAC/D,QAAQ,IAAI,MAAM,CAAC;IACnB,8FAA8F;IAC9F,SAAS,IAAI,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAQD;;;;GAIG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,iBAAiB,EAC1B,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,OAAO,CAAC,CA0BlB"}
@@ -0,0 +1,88 @@
1
+ import { Check, Convert } from "typebox/value";
2
+ import { WorkflowError, WorkflowErrorCode } from "@automatalabs/shared-types";
3
+ /**
4
+ * Find a JSON object/array in free-form text: a fenced ```json block if present, else the
5
+ * first balanced {...} or [...]. Best-effort (the schema check is the real gate). Returns the
6
+ * raw JSON string, or undefined when none is found. (Ported VERBATIM from pi agent.ts.)
7
+ */
8
+ export function findJsonBlock(text) {
9
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
10
+ if (fence?.[1])
11
+ return fence[1].trim();
12
+ const start = text.search(/[{[]/);
13
+ if (start === -1)
14
+ return undefined;
15
+ const open = text[start];
16
+ const close = open === "{" ? "}" : "]";
17
+ let depth = 0;
18
+ for (let i = start; i < text.length; i++) {
19
+ if (text[i] === open)
20
+ depth++;
21
+ else if (text[i] === close && --depth === 0)
22
+ return text.slice(start, i + 1);
23
+ }
24
+ return undefined;
25
+ }
26
+ /** Coerce an already-parsed value toward the schema and accept it only if it then validates. */
27
+ export function validateValue(value, schema) {
28
+ try {
29
+ const converted = Convert(schema, value);
30
+ if (Check(schema, converted))
31
+ return converted;
32
+ }
33
+ catch {
34
+ // typebox can throw on exotic schemas; treat as no match.
35
+ }
36
+ return undefined;
37
+ }
38
+ /**
39
+ * Last-resort structured-output recovery: extract a JSON block from prose, coerce it toward
40
+ * the schema, and accept it only if it then validates. Never fabricates — returns undefined
41
+ * unless the parsed value genuinely satisfies the schema. (Ported VERBATIM from pi agent.ts.)
42
+ */
43
+ export function extractValidated(text, schema) {
44
+ const json = findJsonBlock(text);
45
+ if (json === undefined)
46
+ return undefined;
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(json);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ return validateValue(parsed, schema);
55
+ }
56
+ const REPROMPT_TEXT = [
57
+ "Your previous reply did not return valid JSON matching the required output schema.",
58
+ "Reply now with ONLY a single JSON object that conforms to the schema —",
59
+ "no prose, no explanation, and no markdown code fences.",
60
+ ].join(" ");
61
+ /**
62
+ * Resolve a schema agent's result via the ladder above. Returns the validated value (typed
63
+ * unknown at this layer; the caller casts to the seam's AgentResult<S>). Throws
64
+ * SCHEMA_NONCOMPLIANCE (non-recoverable) when no valid value is produced after the retries.
65
+ */
66
+ export async function resolveStructuredOutput(session, schema, options) {
67
+ const tryResolve = () => {
68
+ const native = session.tryNative();
69
+ if (native !== undefined && native !== null) {
70
+ const validated = validateValue(native, schema);
71
+ if (validated !== undefined)
72
+ return validated;
73
+ }
74
+ return extractValidated(session.lastText(), schema);
75
+ };
76
+ let resolved = tryResolve();
77
+ if (resolved !== undefined)
78
+ return resolved;
79
+ const maxRetries = Math.max(0, options.maxSchemaRetries ?? 2);
80
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
81
+ options.signal?.throwIfAborted();
82
+ await session.prompt(REPROMPT_TEXT);
83
+ resolved = tryResolve();
84
+ if (resolved !== undefined)
85
+ return resolved;
86
+ }
87
+ throw new WorkflowError("Subagent did not produce valid structured output after repair attempts", WorkflowErrorCode.SCHEMA_NONCOMPLIANCE, { recoverable: false, agentLabel: options.label });
88
+ }
@@ -0,0 +1,21 @@
1
+ import type { AgentUsage } from "@automatalabs/shared-types";
2
+ import type { Cost, Usage } from "@agentclientprotocol/sdk";
3
+ export declare class UsageAccumulator {
4
+ private promptUsage;
5
+ private costAmount;
6
+ private contextUsedTokens;
7
+ private contextSizeTokens;
8
+ /** Record the authoritative per-turn token usage from a PromptResponse. */
9
+ recordPromptUsage(usage: Usage | null | undefined): void;
10
+ /** Record the latest cumulative dollar cost carried by a usage_update notification. */
11
+ recordCost(cost: Cost | null | undefined): void;
12
+ /**
13
+ * Record the token counts carried by a usage_update notification: `used` (tokens
14
+ * currently in context) and `size` (context window). These feed `total` as a fallback
15
+ * for backends that report tokens via usage_update but never via PromptResponse.usage,
16
+ * so AgentUsage.total is non-zero whenever the backend reported ANY token count.
17
+ */
18
+ recordContextTokens(used: number | null | undefined, size?: number | null | undefined): void;
19
+ toAgentUsage(): AgentUsage;
20
+ }
21
+ //# sourceMappingURL=usage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../src/usage.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,0BAA0B,CAAC;AAE5D,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,WAAW,CAAoB;IACvC,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,iBAAiB,CAAK;IAE9B,2EAA2E;IAC3E,iBAAiB,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI;IAIxD,uFAAuF;IACvF,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI;IAM/C;;;;;OAKG;IACH,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI;IAK5F,YAAY,IAAI,UAAU;CAuB3B"}
package/dist/usage.js ADDED
@@ -0,0 +1,52 @@
1
+ export class UsageAccumulator {
2
+ promptUsage;
3
+ costAmount = 0;
4
+ contextUsedTokens = 0;
5
+ contextSizeTokens = 0;
6
+ /** Record the authoritative per-turn token usage from a PromptResponse. */
7
+ recordPromptUsage(usage) {
8
+ if (usage)
9
+ this.promptUsage = usage;
10
+ }
11
+ /** Record the latest cumulative dollar cost carried by a usage_update notification. */
12
+ recordCost(cost) {
13
+ if (cost && typeof cost.amount === "number" && Number.isFinite(cost.amount)) {
14
+ this.costAmount = cost.amount;
15
+ }
16
+ }
17
+ /**
18
+ * Record the token counts carried by a usage_update notification: `used` (tokens
19
+ * currently in context) and `size` (context window). These feed `total` as a fallback
20
+ * for backends that report tokens via usage_update but never via PromptResponse.usage,
21
+ * so AgentUsage.total is non-zero whenever the backend reported ANY token count.
22
+ */
23
+ recordContextTokens(used, size) {
24
+ if (typeof used === "number" && Number.isFinite(used) && used >= 0)
25
+ this.contextUsedTokens = used;
26
+ if (typeof size === "number" && Number.isFinite(size) && size >= 0)
27
+ this.contextSizeTokens = size;
28
+ }
29
+ toAgentUsage() {
30
+ const u = this.promptUsage;
31
+ if (u) {
32
+ return {
33
+ input: u.inputTokens ?? 0,
34
+ output: u.outputTokens ?? 0,
35
+ cacheRead: u.cachedReadTokens ?? 0,
36
+ cacheWrite: u.cachedWriteTokens ?? 0,
37
+ total: u.totalTokens ?? 0,
38
+ cost: this.costAmount,
39
+ };
40
+ }
41
+ // No authoritative per-turn breakdown: fall back to the usage_update context tokens so
42
+ // `total` reflects what the backend reported (0 only when NEITHER channel fired).
43
+ return {
44
+ input: 0,
45
+ output: 0,
46
+ cacheRead: 0,
47
+ cacheWrite: 0,
48
+ total: this.contextUsedTokens,
49
+ cost: this.costAmount,
50
+ };
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@automatalabs/acp-agents",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@agentclientprotocol/sdk": "1.0.0",
23
+ "@agentclientprotocol/claude-agent-acp": "0.53.0",
24
+ "@automatalabs/codex-acp": "1.0.2",
25
+ "typebox": "1.3.2",
26
+ "@automatalabs/shared-types": "0.1.0"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -b",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "tsx --test \"test/**/*.test.ts\""
32
+ }
33
+ }