@akagilnc/pi-workflow-roles 0.1.3771 → 0.1.3783
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/acp-host/production-host.js +330 -181
- package/dist/headless-host/description.js +77 -0
- package/dist/headless-host/mcp-relay.mjs +119 -0
- package/dist/headless-host/production-host.js +26590 -0
- package/dist/host-descriptions.js +44 -3
- package/dist/public-cli/load-production-external-host.js +19 -0
- package/dist/public-cli/load-production-headless-host.js +36 -0
- package/dist/public-cli/main.js +242 -162
- package/dist/public-role-summons.js +40 -5
- package/package.json +1 -1
- package/scripts/build-package.mjs +28 -0
- package/src/acp-host/role-envelope.ts +141 -84
- package/src/acp-host/role-turn-host.ts +12 -0
- package/src/headless-host/description.ts +123 -0
- package/src/headless-host/production-host.ts +75 -0
- package/src/headless-host/role-turn-host.ts +424 -0
- package/src/host-descriptions.ts +53 -6
- package/src/public-cli/cli.ts +2 -2
- package/src/public-cli/load-production-external-host.ts +29 -0
- package/src/public-cli/load-production-headless-host.ts +48 -0
- package/src/public-cli/main.ts +5 -0
- package/src/public-role-summons.ts +62 -9
|
@@ -99,9 +99,9 @@ async function createSummonEnv(options) {
|
|
|
99
99
|
const hostName = options.seat.host ?? "pi";
|
|
100
100
|
let roleTurnHost = piHost;
|
|
101
101
|
if (hostName !== "pi") {
|
|
102
|
-
const {
|
|
103
|
-
const {
|
|
104
|
-
if (
|
|
102
|
+
const { lookupHostFamily } = await import("./host-descriptions.js");
|
|
103
|
+
const { loadProductionExternalHostFactory } = await import("./public-cli/load-production-external-host.js");
|
|
104
|
+
if (lookupHostFamily(hostName) === void 0) {
|
|
105
105
|
throw new Error(
|
|
106
106
|
`public role summons host unregistered: host=${hostName} seat=${options.role}`
|
|
107
107
|
);
|
|
@@ -109,7 +109,7 @@ async function createSummonEnv(options) {
|
|
|
109
109
|
let hostPromise;
|
|
110
110
|
roleTurnHost = {
|
|
111
111
|
executeTurn: async (request) => {
|
|
112
|
-
hostPromise ??=
|
|
112
|
+
hostPromise ??= loadProductionExternalHostFactory(options.packageRoot, hostName).then(
|
|
113
113
|
(create) => create({
|
|
114
114
|
packageRoot: options.packageRoot,
|
|
115
115
|
principalAuthority
|
|
@@ -151,7 +151,8 @@ async function summonPublicRole(options) {
|
|
|
151
151
|
} = await import("./public-cli/config.js");
|
|
152
152
|
const credentials = options.credentials ?? await loadCredentialProviders(agentDir);
|
|
153
153
|
const config = await loadPublicCliConfig(home);
|
|
154
|
-
const
|
|
154
|
+
const invocation = options.host === void 0 ? void 0 : { host: options.host };
|
|
155
|
+
const seat = resolveEffectiveSeat(config, options.role, credentials, invocation);
|
|
155
156
|
const env = {
|
|
156
157
|
...await createSummonEnv({
|
|
157
158
|
role: options.role,
|
|
@@ -263,6 +264,38 @@ async function summonPublicRole(options) {
|
|
|
263
264
|
...stderr === void 0 || stderr === "" ? {} : { stderr }
|
|
264
265
|
};
|
|
265
266
|
}
|
|
267
|
+
async function parentInvocationHost(sourceRunDirectory) {
|
|
268
|
+
const { readFile } = await import("node:fs/promises");
|
|
269
|
+
const path = join(sourceRunDirectory, "invocation.json");
|
|
270
|
+
let text;
|
|
271
|
+
try {
|
|
272
|
+
text = await readFile(path, "utf8");
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const code = error instanceof Error && "code" in error ? error.code : void 0;
|
|
275
|
+
throw new Error(
|
|
276
|
+
`parent invocation.json required for gate source run at ${path}: ${code ?? (error instanceof Error ? error.message : String(error))}`,
|
|
277
|
+
{ cause: error }
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
let raw;
|
|
281
|
+
try {
|
|
282
|
+
raw = JSON.parse(text);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`parent invocation.json unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
286
|
+
{ cause: error }
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
290
|
+
throw new Error(`parent invocation.json has non-object shape at ${path}`);
|
|
291
|
+
}
|
|
292
|
+
const host = raw.host;
|
|
293
|
+
if (host === void 0) return void 0;
|
|
294
|
+
if (typeof host !== "string" || host.trim() === "") {
|
|
295
|
+
throw new Error(`parent invocation.json host must be a non-empty string at ${path}`);
|
|
296
|
+
}
|
|
297
|
+
return host;
|
|
298
|
+
}
|
|
266
299
|
async function summonGateOfficer(options) {
|
|
267
300
|
let home = options.home;
|
|
268
301
|
if (home === void 0) {
|
|
@@ -276,6 +309,7 @@ async function summonGateOfficer(options) {
|
|
|
276
309
|
submission: options.submission
|
|
277
310
|
});
|
|
278
311
|
}
|
|
312
|
+
const parentHost = await parentInvocationHost(options.sourceRunDirectory);
|
|
279
313
|
const common = {
|
|
280
314
|
cwd: options.cwd,
|
|
281
315
|
...home === void 0 ? {} : { home },
|
|
@@ -284,6 +318,7 @@ async function summonGateOfficer(options) {
|
|
|
284
318
|
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
285
319
|
...options.reask === void 0 ? {} : { reviewReask: options.reask },
|
|
286
320
|
...gateReviewInstruction === void 0 ? {} : { gateReviewInstruction },
|
|
321
|
+
...parentHost === void 0 ? {} : { host: parentHost },
|
|
287
322
|
...options.roleTurnHost === void 0 ? {} : { roleTurnHost: options.roleTurnHost },
|
|
288
323
|
...options.createRunId === void 0 ? {} : { createRunId: options.createRunId }
|
|
289
324
|
};
|
package/package.json
CHANGED
|
@@ -114,6 +114,33 @@ export async function buildAcpProductionHost(
|
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Deferred generic headless production host artifact (#645). Same peer-free
|
|
119
|
+
* selection face as ACP: loaded only when a headless description-table row is
|
|
120
|
+
* selected.
|
|
121
|
+
*/
|
|
122
|
+
export async function buildHeadlessProductionHost(
|
|
123
|
+
outfile = "dist/headless-host/production-host.js",
|
|
124
|
+
) {
|
|
125
|
+
await mkdir(dirname(outfile), { recursive: true });
|
|
126
|
+
await build({
|
|
127
|
+
entryPoints: ["src/headless-host/production-host.ts"],
|
|
128
|
+
outfile,
|
|
129
|
+
format: "esm",
|
|
130
|
+
platform: "node",
|
|
131
|
+
target: "node20",
|
|
132
|
+
bundle: true,
|
|
133
|
+
packages: "external",
|
|
134
|
+
logLevel: "silent",
|
|
135
|
+
});
|
|
136
|
+
// Shared envelope resolves ./mcp-relay.mjs from import.meta.url of the bundle
|
|
137
|
+
// (#645 headless reuses the ACP relay for AK tools under --mcp-config).
|
|
138
|
+
await copyFile(
|
|
139
|
+
resolve("src/acp-host/mcp-relay.mjs"),
|
|
140
|
+
join(dirname(outfile), "mcp-relay.mjs"),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
export async function buildPackageArtifacts() {
|
|
118
145
|
await build({
|
|
119
146
|
entryPoints: entries.map((name) => `src/${name}.ts`),
|
|
@@ -139,6 +166,7 @@ export async function buildPackageArtifacts() {
|
|
|
139
166
|
}
|
|
140
167
|
await buildPublicAkRoleBin();
|
|
141
168
|
await buildAcpProductionHost();
|
|
169
|
+
await buildHeadlessProductionHost();
|
|
142
170
|
}
|
|
143
171
|
|
|
144
172
|
const isMain =
|
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
RoleTurnKnownFailure,
|
|
16
16
|
RoleTurnRequest,
|
|
17
17
|
} from "../host-contracts.ts";
|
|
18
|
-
import { packagedRoleInputFlag, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
|
|
18
|
+
import { packagedRoleInputFlag, packagedRoleOutputTool, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
|
|
19
19
|
import { stripSkillFrontmatter } from "../package-resources/method-skill.ts";
|
|
20
20
|
import {
|
|
21
21
|
createRoleRuntimeExtension,
|
|
@@ -131,10 +131,33 @@ export function createComposedAcpRoleTurnHost(
|
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** JSON Schema draft-07 document for host-native `--json-schema` (headless). */
|
|
135
|
+
export function terminatingToolJsonSchema(parameters: unknown): Readonly<Record<string, unknown>> {
|
|
136
|
+
const cloned = JSON.parse(JSON.stringify(parameters)) as Record<string, unknown>;
|
|
137
|
+
// $schema last so a newer declaration on the tool parameters cannot override draft-07.
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
...cloned,
|
|
140
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
134
144
|
export async function prepareAcpRoleEnvelope(options: {
|
|
135
145
|
readonly request: RoleTurnRequest;
|
|
136
146
|
readonly dependencies: RoleRuntimeDependencies;
|
|
147
|
+
/**
|
|
148
|
+
* MCP unix socket path for the protocol-relay child. Always required — AK tools
|
|
149
|
+
* ride this single MCP path; headless structured_output reuses the same
|
|
150
|
+
* terminating-tool ledger path without listing the terminating tool on MCP.
|
|
151
|
+
*/
|
|
137
152
|
readonly socketPath: string;
|
|
153
|
+
/**
|
|
154
|
+
* Whether MCP `tools/list` advertises the role terminating tool.
|
|
155
|
+
* ACP keeps it listed (session-tool receipt). Headless hides it so the host
|
|
156
|
+
* native `--json-schema` / structured_output is the sole schema channel
|
|
157
|
+
* (#750 submission-tool-is-schema-channel) and empty MCP probes cannot
|
|
158
|
+
* pre-empt a later structured_output.
|
|
159
|
+
*/
|
|
160
|
+
readonly listTerminatingToolOnMcp?: boolean;
|
|
138
161
|
/**
|
|
139
162
|
* Durable principal session path (header layout only).
|
|
140
163
|
* Production passes DurablePrincipalAuthority.decode(principal).sessionFile so
|
|
@@ -143,6 +166,14 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
143
166
|
readonly sessionFile?: string;
|
|
144
167
|
}): Promise<AcpPreparedTurn> {
|
|
145
168
|
const { request } = options;
|
|
169
|
+
if (options.socketPath === "") {
|
|
170
|
+
throw new Error("prepareAcpRoleEnvelope requires socketPath");
|
|
171
|
+
}
|
|
172
|
+
const listTerminatingToolOnMcp = options.listTerminatingToolOnMcp !== false;
|
|
173
|
+
const earlyTerminatingTool = packagedRoleOutputTool(request.activation.role);
|
|
174
|
+
if (earlyTerminatingTool === undefined) {
|
|
175
|
+
throw new Error(`role has no terminating tool: ${request.activation.role}`);
|
|
176
|
+
}
|
|
146
177
|
const flags = projectAcpActivationFlags(request);
|
|
147
178
|
const tools = new Map<string, HostToolDefinition>();
|
|
148
179
|
const handlers = new Map<string, Handler[]>();
|
|
@@ -403,6 +434,87 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
403
434
|
await emit("tool_execution_end", { toolCallId, toolName, isError: projected.isError });
|
|
404
435
|
return projected;
|
|
405
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Shared terminating/support tool path for MCP relay and headless structured_output.
|
|
439
|
+
* Books the call, runs execute, projects tool_result; does not emit turn_end (closeRound).
|
|
440
|
+
*/
|
|
441
|
+
async function invokeAkTool(name: string, args: unknown): Promise<{
|
|
442
|
+
content: ContentPart[];
|
|
443
|
+
isError: boolean;
|
|
444
|
+
blocked?: true;
|
|
445
|
+
}> {
|
|
446
|
+
const tool = tools.get(name);
|
|
447
|
+
if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
|
|
448
|
+
const toolCallId = randomUUID();
|
|
449
|
+
calls.push({ toolCallId, toolName: name });
|
|
450
|
+
// First-record-then-audit: book the tool-call leaf in memory before execute so
|
|
451
|
+
// judge/doctor subject gates see the candidate on parent session books.
|
|
452
|
+
sessionEntries.push({
|
|
453
|
+
type: "message",
|
|
454
|
+
message: {
|
|
455
|
+
role: "assistant" as const,
|
|
456
|
+
content: [{
|
|
457
|
+
type: "toolCall",
|
|
458
|
+
id: toolCallId,
|
|
459
|
+
name,
|
|
460
|
+
arguments: args ?? {},
|
|
461
|
+
}],
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
await emit("tool_execution_start", { toolCallId, toolName: name });
|
|
466
|
+
const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: (args ?? {}) as Record<string, unknown> }))
|
|
467
|
+
.some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
|
|
468
|
+
if (blocked) {
|
|
469
|
+
// Lawful seatbelt/block stays bare rejection — not infrastructure (#593 r3).
|
|
470
|
+
return { content: [{ type: "text", text: `AK tool blocked: ${name}` }], isError: true, blocked: true };
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
// Pre-execution emit failure shares the non-correctable infra pathway (#593 r3).
|
|
474
|
+
const declared = declareRoundInfrastructureFailure(error);
|
|
475
|
+
try {
|
|
476
|
+
const projected = await projectToolResult(toolCallId, name, {
|
|
477
|
+
content: declared.content,
|
|
478
|
+
details: declared.details,
|
|
479
|
+
isError: true,
|
|
480
|
+
});
|
|
481
|
+
return { content: projected.content, isError: true };
|
|
482
|
+
} catch {
|
|
483
|
+
return { content: declared.content, isError: true };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const result = await tool.execute(toolCallId, (args ?? {}) as never, undefined, undefined, context);
|
|
488
|
+
const projected = await projectToolResult(toolCallId, name, {
|
|
489
|
+
content: result.content,
|
|
490
|
+
details: result.details,
|
|
491
|
+
isError: false,
|
|
492
|
+
});
|
|
493
|
+
// Candidate only: seal waits for closeRound after the host round boundary.
|
|
494
|
+
return { content: projected.content, isError: projected.isError };
|
|
495
|
+
} catch (error) {
|
|
496
|
+
let content: ContentPart[];
|
|
497
|
+
let details: Record<string, unknown>;
|
|
498
|
+
if (isCorrectableExecuteError(error)) {
|
|
499
|
+
const projected = projectCorrectableExecuteRejection(error);
|
|
500
|
+
content = [{ type: "text", text: projected.diagnostic }];
|
|
501
|
+
details = projected.details;
|
|
502
|
+
} else {
|
|
503
|
+
// Slot-before-abort; projectToolResult may still project durable details.
|
|
504
|
+
({ content, details } = declareRoundInfrastructureFailure(error));
|
|
505
|
+
}
|
|
506
|
+
// The shared envelope's tool_result handler is the sole classifier:
|
|
507
|
+
// it projects either the structured submission non-pass (correctable
|
|
508
|
+
// rejection) or the typed infrastructure fact onto the reply.
|
|
509
|
+
const projected = await projectToolResult(toolCallId, name, {
|
|
510
|
+
content,
|
|
511
|
+
details,
|
|
512
|
+
isError: true,
|
|
513
|
+
});
|
|
514
|
+
return { content: projected.content, isError: projected.isError };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
406
518
|
function reply(socket: Socket, id: number, result?: unknown, error?: unknown): void {
|
|
407
519
|
const rpcError = error instanceof Error
|
|
408
520
|
? { code: "ak-relay-failure", name: error.name, message: error.message }
|
|
@@ -424,7 +536,9 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
424
536
|
if (rpc.token !== token) { reply(socket, rpc.id, undefined, "unauthorized relay"); return; }
|
|
425
537
|
try {
|
|
426
538
|
if (rpc.method === "tools/list") {
|
|
427
|
-
|
|
539
|
+
const listed = [...tools.values()].filter((tool) =>
|
|
540
|
+
listTerminatingToolOnMcp || tool.name !== earlyTerminatingTool);
|
|
541
|
+
reply(socket, rpc.id, { tools: listed.map((tool) => {
|
|
428
542
|
return { name: tool.name, description: tool.description, inputSchema: tool.parameters };
|
|
429
543
|
}) });
|
|
430
544
|
return;
|
|
@@ -433,95 +547,24 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
433
547
|
const params = rpc.params as ToolCallParams | undefined;
|
|
434
548
|
const name = params?.name;
|
|
435
549
|
if (typeof name !== "string") throw new Error("MCP tool name is missing");
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// First-record-then-audit: book the tool-call leaf in memory before execute so
|
|
441
|
-
// judge/doctor subject gates see the candidate on parent session books.
|
|
442
|
-
{
|
|
443
|
-
const message = {
|
|
444
|
-
role: "assistant" as const,
|
|
445
|
-
content: [{
|
|
446
|
-
type: "toolCall",
|
|
447
|
-
id: toolCallId,
|
|
448
|
-
name,
|
|
449
|
-
arguments: params?.arguments ?? {},
|
|
450
|
-
}],
|
|
451
|
-
};
|
|
452
|
-
sessionEntries.push({ type: "message", message });
|
|
550
|
+
// Headless schema channel owns the terminating receipt — refuse MCP
|
|
551
|
+
// terminating calls so an empty probe cannot book a non-sealable candidate.
|
|
552
|
+
if (!listTerminatingToolOnMcp && name === earlyTerminatingTool) {
|
|
553
|
+
throw new Error(`terminating tool ${name} is schema-channel only on this host`);
|
|
453
554
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
.some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
|
|
458
|
-
if (blocked) {
|
|
459
|
-
// Lawful seatbelt/block stays bare RPC rejection — not infrastructure (#593 r3).
|
|
460
|
-
reply(socket, rpc.id, undefined, new Error(`AK tool blocked: ${name}`));
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
} catch (error) {
|
|
464
|
-
// Pre-execution emit failure (e.g. observation writer) shares the same
|
|
465
|
-
// non-correctable infra pathway as execute throws (#593 r3).
|
|
466
|
-
const declared = declareRoundInfrastructureFailure(error);
|
|
467
|
-
try {
|
|
468
|
-
const projected = await projectToolResult(toolCallId, name, {
|
|
469
|
-
content: declared.content,
|
|
470
|
-
details: declared.details,
|
|
471
|
-
isError: true,
|
|
472
|
-
});
|
|
473
|
-
reply(socket, rpc.id, {
|
|
474
|
-
content: projected.content,
|
|
475
|
-
isError: true,
|
|
476
|
-
});
|
|
477
|
-
} catch {
|
|
478
|
-
// Slot already filled; durable projection may have partially failed.
|
|
479
|
-
reply(socket, rpc.id, {
|
|
480
|
-
content: declared.content,
|
|
481
|
-
isError: true,
|
|
482
|
-
});
|
|
483
|
-
}
|
|
555
|
+
const outcome = await invokeAkTool(name, params?.arguments ?? {});
|
|
556
|
+
if (outcome.blocked === true) {
|
|
557
|
+
reply(socket, rpc.id, undefined, new Error(outcome.content.map((p) => p.type === "text" ? p.text : "").join("")));
|
|
484
558
|
return;
|
|
485
559
|
}
|
|
486
|
-
|
|
487
|
-
const result = await tool.execute(toolCallId, (params?.arguments ?? {}) as never, undefined, undefined, context);
|
|
488
|
-
const projected = await projectToolResult(toolCallId, name, {
|
|
489
|
-
content: result.content,
|
|
490
|
-
details: result.details,
|
|
491
|
-
isError: false,
|
|
492
|
-
});
|
|
493
|
-
// Candidate only: do not emit turn_end here. Seal waits for the typed ACP
|
|
494
|
-
// round boundary (closeRound after session/prompt), so delayed siblings stay
|
|
495
|
-
// in the same round instead of becoming silent post-seal anomalies.
|
|
496
|
-
reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
|
|
497
|
-
} catch (error) {
|
|
498
|
-
let content: ContentPart[];
|
|
499
|
-
let details: Record<string, unknown>;
|
|
500
|
-
if (isCorrectableExecuteError(error)) {
|
|
501
|
-
const projected = projectCorrectableExecuteRejection(error);
|
|
502
|
-
content = [{ type: "text", text: projected.diagnostic }];
|
|
503
|
-
details = projected.details;
|
|
504
|
-
} else {
|
|
505
|
-
// Slot-before-abort; projectToolResult may still project durable details.
|
|
506
|
-
({ content, details } = declareRoundInfrastructureFailure(error));
|
|
507
|
-
}
|
|
508
|
-
// The shared envelope's tool_result handler is the sole classifier:
|
|
509
|
-
// it projects either the structured submission non-pass (correctable
|
|
510
|
-
// rejection) or the typed infrastructure fact onto the reply.
|
|
511
|
-
const projected = await projectToolResult(toolCallId, name, {
|
|
512
|
-
content,
|
|
513
|
-
details,
|
|
514
|
-
isError: true,
|
|
515
|
-
});
|
|
516
|
-
reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
|
|
517
|
-
}
|
|
560
|
+
reply(socket, rpc.id, { content: outcome.content, ...(outcome.isError ? { isError: true } : {}) });
|
|
518
561
|
} catch (error) { reply(socket, rpc.id, undefined, error); }
|
|
519
562
|
})();
|
|
520
563
|
}
|
|
521
564
|
});
|
|
522
565
|
}
|
|
523
|
-
await listen(server, options.socketPath);
|
|
524
566
|
const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
|
|
567
|
+
await listen(server, options.socketPath);
|
|
525
568
|
let disposed = false;
|
|
526
569
|
// Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
|
|
527
570
|
// AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
|
|
@@ -552,8 +595,6 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
552
595
|
} catch (error) {
|
|
553
596
|
cleanupFailures.push(error);
|
|
554
597
|
}
|
|
555
|
-
// Server closure is unconditional: a shutdown-handler failure must not leave
|
|
556
|
-
// the listening MCP server keeping the completed invocation alive.
|
|
557
598
|
try {
|
|
558
599
|
const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
|
|
559
600
|
if (typeof closeAll === "function") closeAll.call(server);
|
|
@@ -571,6 +612,14 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
571
612
|
}
|
|
572
613
|
};
|
|
573
614
|
|
|
615
|
+
const terminatingToolName: string = earlyTerminatingTool;
|
|
616
|
+
async function ingestStructuredOutput(params: unknown): Promise<void> {
|
|
617
|
+
// MCP path may already have invoked the terminating tool this round; skip the
|
|
618
|
+
// duplicate so structured_output + tool-call does not arm non-sole.
|
|
619
|
+
if (calls.some((call) => call.toolName === terminatingToolName)) return;
|
|
620
|
+
await invokeAkTool(terminatingToolName, params ?? {});
|
|
621
|
+
}
|
|
622
|
+
|
|
574
623
|
const closeRound: AcpPreparedTurn["closeRound"] = async () => {
|
|
575
624
|
// Typed round boundary: hand the complete call list to the shared ledger once.
|
|
576
625
|
if (calls.length > 0) {
|
|
@@ -654,6 +703,11 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
654
703
|
else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
|
|
655
704
|
runDirInjected = true;
|
|
656
705
|
|
|
706
|
+
const terminating = tools.get(terminatingToolName);
|
|
707
|
+
if (terminating === undefined) {
|
|
708
|
+
throw new Error(`terminating tool not registered after activation: ${terminatingToolName}`);
|
|
709
|
+
}
|
|
710
|
+
const jsonSchema = terminatingToolJsonSchema(terminating.parameters);
|
|
657
711
|
return {
|
|
658
712
|
mcpServers: [{
|
|
659
713
|
name: `ak-${request.activation.role}`,
|
|
@@ -669,6 +723,9 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
669
723
|
abortSignal: hostAbort.signal,
|
|
670
724
|
closeRound,
|
|
671
725
|
dispose,
|
|
726
|
+
jsonSchema,
|
|
727
|
+
terminatingToolName,
|
|
728
|
+
ingestStructuredOutput,
|
|
672
729
|
};
|
|
673
730
|
} catch (error) {
|
|
674
731
|
// listen already succeeded; dispose is not yet caller-owned. Release the
|
|
@@ -43,6 +43,18 @@ export type AcpPreparedTurn = Readonly<{
|
|
|
43
43
|
| { readonly accepted: false; readonly failure: RoleTurnKnownFailure }
|
|
44
44
|
>;
|
|
45
45
|
dispose?(): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Headless CLI family (#645): role terminating-tool schema for host-native
|
|
48
|
+
* `--json-schema`. Present for every prepared turn; ACP ignores it.
|
|
49
|
+
*/
|
|
50
|
+
jsonSchema: Readonly<Record<string, unknown>>;
|
|
51
|
+
/** Terminating tool name whose schema is `jsonSchema`. */
|
|
52
|
+
terminatingToolName: string;
|
|
53
|
+
/**
|
|
54
|
+
* Headless CLI family: feed host-native `structured_output` through the same
|
|
55
|
+
* terminating-tool path the MCP relay uses (ledger + gates). ACP ignores it.
|
|
56
|
+
*/
|
|
57
|
+
ingestStructuredOutput(params: unknown): Promise<void>;
|
|
46
58
|
}>;
|
|
47
59
|
|
|
48
60
|
/** Fold structured system-prompt authority into the provider-visible ACP override. */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One headless CLI host description (#645 / #752).
|
|
3
|
+
* Every host-specific value the generic headless adapter needs — binary, argv
|
|
4
|
+
* shape, session binding — is data here; lifecycle stays one copy so #646 codex
|
|
5
|
+
* is another row, not a fork.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export type HeadlessHostDescription = Readonly<{
|
|
10
|
+
/** Binary path segments relative to the operator home. */
|
|
11
|
+
binaryFromHome: readonly string[];
|
|
12
|
+
/** Durable session-id binding filename beside the session principal. */
|
|
13
|
+
sessionBindingFile: string;
|
|
14
|
+
/**
|
|
15
|
+
* Host-native print-mode flags that never change per turn (no prompt).
|
|
16
|
+
* Model / effort / system-prompt / schema / session / resume / mcp-config
|
|
17
|
+
* are composed by the adapter from the turn request — not listed here.
|
|
18
|
+
*/
|
|
19
|
+
fixedArgs: readonly string[];
|
|
20
|
+
/** Print-mode flag that takes the user prompt as its value (e.g. `-p`). */
|
|
21
|
+
promptFlag: string;
|
|
22
|
+
/** CLI flag for the seat model (e.g. `--model`). */
|
|
23
|
+
modelFlag: string;
|
|
24
|
+
/** CLI flag for the seat thinking level (e.g. `--effort`); value is opaque pass-through. */
|
|
25
|
+
effortFlag: string;
|
|
26
|
+
/**
|
|
27
|
+
* CLI flag whose value is a path to the system-prompt file
|
|
28
|
+
* (`--system-prompt-file`). File delivery keeps ARG_MAX off the critical path.
|
|
29
|
+
*/
|
|
30
|
+
systemPromptFlag: string;
|
|
31
|
+
/** CLI flag whose value is a JSON Schema document string. */
|
|
32
|
+
jsonSchemaFlag: string;
|
|
33
|
+
/** CLI flag whose value is a path or JSON string for MCP servers. */
|
|
34
|
+
mcpConfigFlag: string;
|
|
35
|
+
/** CLI flag to mint a fresh session id (initial turn). */
|
|
36
|
+
sessionIdFlag: string;
|
|
37
|
+
/** CLI flag to resume a prior session id. */
|
|
38
|
+
resumeFlag: string;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
/** Absolute agent binary for one operator home. */
|
|
42
|
+
export function resolveHeadlessBinary(
|
|
43
|
+
description: HeadlessHostDescription,
|
|
44
|
+
operatorHome: string,
|
|
45
|
+
): string {
|
|
46
|
+
return join(operatorHome, ...description.binaryFromHome);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build one headless CLI argv for a single process turn.
|
|
51
|
+
* Shape: `<promptFlag> <prompt> <fixedArgs…> <system/schema/mcp/model/effort/session…>`.
|
|
52
|
+
*/
|
|
53
|
+
export function headlessTurnArgs(options: {
|
|
54
|
+
readonly description: HeadlessHostDescription;
|
|
55
|
+
readonly prompt: string;
|
|
56
|
+
/** Absolute path written by the adapter; paired with `systemPromptFlag`. */
|
|
57
|
+
readonly systemPromptPath: string;
|
|
58
|
+
readonly jsonSchema: Readonly<Record<string, unknown>>;
|
|
59
|
+
/** Absolute path to host-native MCP config JSON; omitted when no AK MCP servers. */
|
|
60
|
+
readonly mcpConfigPath?: string;
|
|
61
|
+
readonly model?: string;
|
|
62
|
+
readonly effort?: string;
|
|
63
|
+
/** Fresh session: pass as session id. Resume: pass as resume id. */
|
|
64
|
+
readonly session: { readonly kind: "new"; readonly id: string } | { readonly kind: "resume"; readonly id: string };
|
|
65
|
+
}): string[] {
|
|
66
|
+
const { description } = options;
|
|
67
|
+
const args: string[] = [
|
|
68
|
+
description.promptFlag,
|
|
69
|
+
options.prompt,
|
|
70
|
+
...description.fixedArgs,
|
|
71
|
+
description.systemPromptFlag,
|
|
72
|
+
options.systemPromptPath,
|
|
73
|
+
description.jsonSchemaFlag,
|
|
74
|
+
JSON.stringify(options.jsonSchema),
|
|
75
|
+
];
|
|
76
|
+
if (options.mcpConfigPath !== undefined && options.mcpConfigPath !== "") {
|
|
77
|
+
args.push(description.mcpConfigFlag, options.mcpConfigPath);
|
|
78
|
+
}
|
|
79
|
+
if (options.model !== undefined && options.model !== "") {
|
|
80
|
+
args.push(description.modelFlag, options.model);
|
|
81
|
+
}
|
|
82
|
+
if (options.effort !== undefined && options.effort !== "") {
|
|
83
|
+
args.push(description.effortFlag, options.effort);
|
|
84
|
+
}
|
|
85
|
+
if (options.session.kind === "new") {
|
|
86
|
+
args.push(description.sessionIdFlag, options.session.id);
|
|
87
|
+
} else {
|
|
88
|
+
args.push(description.resumeFlag, options.session.id);
|
|
89
|
+
}
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Project shared-envelope MCP server rows into Claude `--mcp-config` JSON.
|
|
95
|
+
* Env stays a plain object (Claude CLI shape); ACP rows use `{name,value}[]`.
|
|
96
|
+
*/
|
|
97
|
+
export function headlessMcpConfigDocument(
|
|
98
|
+
mcpServers: readonly Readonly<Record<string, unknown>>[],
|
|
99
|
+
): Readonly<{ mcpServers: Readonly<Record<string, Readonly<Record<string, unknown>>>> }> {
|
|
100
|
+
const servers: Record<string, Record<string, unknown>> = {};
|
|
101
|
+
for (const row of mcpServers) {
|
|
102
|
+
const name = typeof row.name === "string" ? row.name : undefined;
|
|
103
|
+
const command = typeof row.command === "string" ? row.command : undefined;
|
|
104
|
+
if (name === undefined || name === "" || command === undefined || command === "") continue;
|
|
105
|
+
const entry: Record<string, unknown> = { command };
|
|
106
|
+
if (Array.isArray(row.args)) entry.args = row.args;
|
|
107
|
+
if (Array.isArray(row.env)) {
|
|
108
|
+
const env: Record<string, string> = {};
|
|
109
|
+
for (const item of row.env) {
|
|
110
|
+
if (typeof item !== "object" || item === null) continue;
|
|
111
|
+
const record = item as { name?: unknown; value?: unknown };
|
|
112
|
+
if (typeof record.name === "string" && typeof record.value === "string") {
|
|
113
|
+
env[record.name] = record.value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(env).length > 0) entry.env = env;
|
|
117
|
+
} else if (typeof row.env === "object" && row.env !== null && !Array.isArray(row.env)) {
|
|
118
|
+
entry.env = row.env;
|
|
119
|
+
}
|
|
120
|
+
servers[name] = entry;
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze({ mcpServers: Object.freeze(servers) });
|
|
123
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production composition for the generic headless CLI RoleTurnHost (#645).
|
|
3
|
+
* Agent subprocesses inherit the operator home and credentials in place.
|
|
4
|
+
* No HOME rewrite, no isolated home, no credential parameters — CLI owns auth.
|
|
5
|
+
* Sitian records on the run are the dossier; host private sessions stay private.
|
|
6
|
+
*
|
|
7
|
+
* Intermediate AK tools ride the shared envelope MCP relay via host-native
|
|
8
|
+
* `--mcp-config` under `--strict-mcp-config`. The terminating receipt is the
|
|
9
|
+
* host-native `--json-schema` / structured_output schema channel only
|
|
10
|
+
* (#750 submission-tool-is-schema-channel) — terminating tool is not listed on MCP.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
import type { DurablePrincipalAuthority, RoleTurnHost } from "../host-contracts.ts";
|
|
15
|
+
import { createAcpRoleRuntimeDependencies } from "../acp-host/production-host.ts";
|
|
16
|
+
import { prepareAcpRoleEnvelope } from "../acp-host/role-envelope.ts";
|
|
17
|
+
import { createAcpSessionIdentityAuthority } from "../acp-host/session-identity.ts";
|
|
18
|
+
import { resolveHeadlessBinary, type HeadlessHostDescription } from "./description.ts";
|
|
19
|
+
import { createHeadlessRoleTurnHost } from "./role-turn-host.ts";
|
|
20
|
+
|
|
21
|
+
export type ProductionHeadlessHostOptions = Readonly<{
|
|
22
|
+
packageRoot: string;
|
|
23
|
+
principalAuthority: DurablePrincipalAuthority;
|
|
24
|
+
description: HeadlessHostDescription;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Assemble a production headless RoleTurnHost from the shared envelope prepare
|
|
29
|
+
* (MCP relay for AK tools) and one host description row. Binary is resolved
|
|
30
|
+
* from each turn's operator home (`request.home`).
|
|
31
|
+
*/
|
|
32
|
+
export function createProductionHeadlessRoleTurnHost(
|
|
33
|
+
options: ProductionHeadlessHostOptions,
|
|
34
|
+
): RoleTurnHost {
|
|
35
|
+
const { packageRoot, principalAuthority, description } = options;
|
|
36
|
+
const sessionIdentity = createAcpSessionIdentityAuthority(
|
|
37
|
+
principalAuthority,
|
|
38
|
+
description.sessionBindingFile,
|
|
39
|
+
);
|
|
40
|
+
const roleRuntimeDependencies = createAcpRoleRuntimeDependencies(packageRoot);
|
|
41
|
+
|
|
42
|
+
const innerFor = (operatorHome: string): RoleTurnHost =>
|
|
43
|
+
createHeadlessRoleTurnHost({
|
|
44
|
+
description,
|
|
45
|
+
sessionIdentity,
|
|
46
|
+
binary: resolveHeadlessBinary(description, operatorHome),
|
|
47
|
+
env: {
|
|
48
|
+
...process.env,
|
|
49
|
+
AK_PACKAGE_ROOT: packageRoot,
|
|
50
|
+
},
|
|
51
|
+
prepare: (request) =>
|
|
52
|
+
prepareAcpRoleEnvelope({
|
|
53
|
+
request,
|
|
54
|
+
dependencies: roleRuntimeDependencies,
|
|
55
|
+
sessionFile: sessionIdentity.resolveSessionFile(request.principal),
|
|
56
|
+
// Same MCP relay as ACP so intermediate AK tools stay reachable;
|
|
57
|
+
// headless adapter projects the row into --mcp-config.
|
|
58
|
+
socketPath: `/tmp/ak-headless-mcp-${randomUUID()}.sock`,
|
|
59
|
+
// Schema channel owns the terminating receipt; hide it from MCP list.
|
|
60
|
+
listTerminatingToolOnMcp: false,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let cachedHome: string | undefined;
|
|
65
|
+
let cachedHost: RoleTurnHost | undefined;
|
|
66
|
+
return {
|
|
67
|
+
executeTurn(request) {
|
|
68
|
+
if (cachedHost === undefined || cachedHome !== request.home) {
|
|
69
|
+
cachedHome = request.home;
|
|
70
|
+
cachedHost = innerFor(request.home);
|
|
71
|
+
}
|
|
72
|
+
return cachedHost.executeTurn(request);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|