@fastagent-sh/fastagent 0.16.2 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels/agentcore.d.ts +16 -2
- package/dist/channels/agentcore.js +90 -9
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +2 -1
- package/dist/channels/feishu/card.d.ts +20 -9
- package/dist/channels/feishu/card.js +27 -13
- package/dist/channels/feishu/feishu-api.d.ts +11 -2
- package/dist/channels/feishu/feishu.js +84 -10
- package/dist/channels/feishu/invoke-turn.d.ts +4 -0
- package/dist/channels/feishu/invoke-turn.js +31 -5
- package/dist/channels/feishu/normalize.js +97 -32
- package/dist/channels/feishu/preview.d.ts +4 -3
- package/dist/channels/feishu/preview.js +77 -23
- package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
- package/dist/channels/lark/scaffold/lark-send.ts +9 -6
- package/dist/cli/commands/attach.d.ts +18 -0
- package/dist/cli/commands/attach.js +46 -2
- package/dist/cli/commands/dev.js +2 -2
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/start.js +41 -7
- package/dist/cli/program.js +2 -2
- package/dist/cli/serve.d.ts +4 -0
- package/dist/cli/serve.js +2 -2
- package/dist/deploy/agentcore/logs.d.ts +10 -5
- package/dist/deploy/agentcore/logs.js +2 -5
- package/dist/deploy/agentcore/plan.d.ts +3 -2
- package/dist/deploy/agentcore/plan.js +23 -5
- package/dist/deploy/agentcore/run.d.ts +7 -1
- package/dist/deploy/agentcore/run.js +93 -8
- package/dist/deploy/preflight.js +34 -7
- package/dist/engines/pi/create.js +7 -16
- package/dist/engines/pi/definition.d.ts +15 -0
- package/dist/engines/pi/definition.js +22 -1
- package/dist/engines/pi/open.d.ts +1 -1
- package/dist/engines/pi/open.js +19 -0
- package/dist/engines/pi/report.d.ts +16 -0
- package/dist/engines/pi/report.js +30 -0
- package/dist/engines/pi/session-builder.js +2 -2
- package/dist/engines/pi/session-control.d.ts +11 -1
- package/dist/engines/pi/session-control.js +3 -0
- package/dist/session-remote.js +17 -0
- package/dist/session.d.ts +20 -0
- package/package.json +1 -1
package/dist/cli/serve.js
CHANGED
|
@@ -151,9 +151,9 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
|
151
151
|
* the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
|
|
152
152
|
*/
|
|
153
153
|
export function mountAgentcore(routes, options) {
|
|
154
|
-
const { agent, stateRoot, schedules, onStateReady } = options;
|
|
154
|
+
const { agent, stateRoot, schedules, onStateReady, lazyChannels } = options;
|
|
155
155
|
const mounted = agentcoreRoutes({
|
|
156
|
-
routes,
|
|
156
|
+
routes: lazyChannels ?? routes,
|
|
157
157
|
agent,
|
|
158
158
|
stateRoot,
|
|
159
159
|
isBusy: () => activeWork() > 0,
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AgentCore log discovery + tailing.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* AgentCore log discovery + tailing. The container's stdout/stderr lives in a per-endpoint CloudWatch
|
|
3
|
+
* log group, while the forwarder Lambda has a separate group. This operator surface resolves the
|
|
4
|
+
* stack's RuntimeArn, discovers the endpoint group by prefix, and tails it.
|
|
5
|
+
*
|
|
6
|
+
* NO STREAM FILTER, deliberately: AgentCore names its streams `YYYY/MM/DD/[runtime-logs]<session-id>`
|
|
7
|
+
* (the Lambda `2024/01/01/[$LATEST]abc` convention), so `[runtime-logs]` is an INFIX after the UTC date
|
|
8
|
+
* path, not a prefix. `--log-stream-name-prefix` is a literal prefix match, and the AWS CLI has no
|
|
9
|
+
* substring filter (`--log-stream-names` takes exact names, which `--follow` could never extend to the
|
|
10
|
+
* new session streams). Passing the marker as a prefix therefore matches zero streams and `aws logs
|
|
11
|
+
* tail` prints nothing and exits 0 — a silent empty tail. Do not add it back.
|
|
7
12
|
*/
|
|
8
13
|
import type { CliRunner } from "../runner.ts";
|
|
9
14
|
export type AgentcoreLogSource = "runtime" | "forwarder";
|
|
@@ -93,9 +93,8 @@ export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
|
|
|
93
93
|
if (matches.length > 1) {
|
|
94
94
|
return {
|
|
95
95
|
ok: false,
|
|
96
|
-
gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one
|
|
97
|
-
`
|
|
98
|
-
`(quote the prefix — [...] is a shell glob)`,
|
|
96
|
+
gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one directly: ` +
|
|
97
|
+
`aws logs tail <group> --format short --follow`,
|
|
99
98
|
};
|
|
100
99
|
}
|
|
101
100
|
const logGroup = matches[0];
|
|
@@ -103,8 +102,6 @@ export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
|
|
|
103
102
|
const tailArgs = ["logs", "tail", logGroup, "--format", "short"];
|
|
104
103
|
if (plan.since)
|
|
105
104
|
tailArgs.push("--since", plan.since);
|
|
106
|
-
if (plan.source === "runtime")
|
|
107
|
-
tailArgs.push("--log-stream-name-prefix", "[runtime-logs]");
|
|
108
105
|
if (plan.follow)
|
|
109
106
|
tailArgs.push("--follow");
|
|
110
107
|
const tailed = await aws(tailArgs);
|
|
@@ -63,8 +63,9 @@ export declare const SECRETS_DIR = "/mnt/state/.secrets";
|
|
|
63
63
|
* — idle included, at the peak level reached — so this tail is the standing cost of every burst of
|
|
64
64
|
* activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
|
|
65
65
|
* platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
|
|
66
|
-
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy
|
|
67
|
-
*
|
|
66
|
+
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy + time_of_last_update while
|
|
67
|
+
* work is in flight (the FIELD is what the platform's idle measurement actually reads — agentcore.ts),
|
|
68
|
+
* so this timer only ever starts once the agent has genuinely settled — a long turn is never cut short.
|
|
68
69
|
* AWS accepts 60–28800.
|
|
69
70
|
*/
|
|
70
71
|
export declare const IDLE_TIMEOUT_SECONDS = 180;
|
|
@@ -62,8 +62,9 @@ export const SECRETS_DIR = `${MOUNT}/${SECRETS_DIRNAME}`;
|
|
|
62
62
|
* — idle included, at the peak level reached — so this tail is the standing cost of every burst of
|
|
63
63
|
* activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
|
|
64
64
|
* platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
|
|
65
|
-
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy
|
|
66
|
-
*
|
|
65
|
+
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy + time_of_last_update while
|
|
66
|
+
* work is in flight (the FIELD is what the platform's idle measurement actually reads — agentcore.ts),
|
|
67
|
+
* so this timer only ever starts once the agent has genuinely settled — a long turn is never cut short.
|
|
67
68
|
* AWS accepts 60–28800.
|
|
68
69
|
*/
|
|
69
70
|
export const IDLE_TIMEOUT_SECONDS = 180;
|
|
@@ -395,6 +396,23 @@ exports.handler = async (event, ctx) => {
|
|
|
395
396
|
if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
|
|
396
397
|
return { statusCode: 200, body: "ok\\n" };
|
|
397
398
|
}
|
|
399
|
+
// The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
|
|
400
|
+
// trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
|
|
401
|
+
// them, and would make the runtime construct against a pre-restore mount) and pass its structured
|
|
402
|
+
// transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
|
|
403
|
+
// into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
|
|
404
|
+
// sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
|
|
405
|
+
// public traffic) are probeable too.
|
|
406
|
+
if (event.rawPath === "/__fastagent/probe") {
|
|
407
|
+
const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
|
|
408
|
+
if (!process.env.INGRESS_SECRET || req.auth !== process.env.INGRESS_SECRET) return { statusCode: 403, body: "forbidden\\n" };
|
|
409
|
+
const r = await invoke({ kind: "probe" });
|
|
410
|
+
if (r.status !== 200) {
|
|
411
|
+
console.log(\`probe transport error \${r.status}: \${r.body}\`);
|
|
412
|
+
return { statusCode: 502, body: "upstream error\\n" };
|
|
413
|
+
}
|
|
414
|
+
return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
|
|
415
|
+
}
|
|
398
416
|
// Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
|
|
399
417
|
// envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
|
|
400
418
|
const webhookBytes = event.body === undefined ? 0
|
|
@@ -562,7 +580,7 @@ function template(input, translated) {
|
|
|
562
580
|
` # forces a NAT gateway for model/channel egress (~$33/mo) — deliberately not the default.`,
|
|
563
581
|
` FilesystemConfigurations:`,
|
|
564
582
|
` - SessionStorage: { MountPath: ${MOUNT} }`,
|
|
565
|
-
` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy keeps BUSY sessions alive
|
|
583
|
+
` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy + time_of_last_update keeps BUSY sessions alive), max compute`,
|
|
566
584
|
` # lifetime ${MAX_LIFETIME_SECONDS}s — the platform ceiling; the session id stays valid, so the next invoke`,
|
|
567
585
|
` # just gets fresh compute with the same storage. Memory bills per second for the whole`,
|
|
568
586
|
` # session INCLUDING the idle tail, so a shorter tail is the main cost lever here.`,
|
|
@@ -712,7 +730,7 @@ export function planAgentcoreDeploy(input) {
|
|
|
712
730
|
? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
|
|
713
731
|
: `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
|
|
714
732
|
? []
|
|
715
|
-
: [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`, ``, `# 5. Tail the Runtime's application stdout/stderr (same fastagent messages + log level as locally).`, `# Discovery
|
|
733
|
+
: [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`, ``, `# 5. Tail the Runtime's application stdout/stderr (same fastagent messages + log level as locally).`, `# Discovery resolves the per-endpoint log group from the stack's RuntimeArn:`, `fastagent logs agentcore --follow`, ...(needsForwarder
|
|
716
734
|
? [
|
|
717
735
|
`# The ingress transport is a separate Lambda and therefore a separate log source:`,
|
|
718
736
|
`fastagent logs agentcore --source forwarder --follow`,
|
|
@@ -732,7 +750,7 @@ export function planAgentcoreDeploy(input) {
|
|
|
732
750
|
post.push(`# Register the Telegram webhook (default route POST /telegram; secret_token MUST equal TELEGRAM_SECRET_TOKEN):`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=<ForwarderUrl>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
|
|
733
751
|
}
|
|
734
752
|
if (channels.includes("github")) {
|
|
735
|
-
post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy holds the session while turns run, but the 8 h compute ceiling is hard).`);
|
|
753
|
+
post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
|
|
736
754
|
}
|
|
737
755
|
if (channels.includes("slack")) {
|
|
738
756
|
post.push(`# Set Slack Event Subscriptions → Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
|
|
@@ -70,4 +70,10 @@ export declare function parseCheckpointReply(stdout: string): CheckpointReply |
|
|
|
70
70
|
* post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
|
|
71
71
|
* gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
|
|
72
72
|
*/
|
|
73
|
-
export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome
|
|
73
|
+
export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>,
|
|
74
|
+
/** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
|
|
75
|
+
probe?: {
|
|
76
|
+
fetchImpl?: typeof fetch;
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
intervalMs?: number;
|
|
79
|
+
}): Promise<AgentcoreRunOutcome>;
|
|
@@ -3,6 +3,66 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { Buffer } from "node:buffer";
|
|
4
4
|
import { AUTH_SEED_CHUNK_SIZE, AUTH_SEED_MAX_CHUNKS, cfnParamName, forwarderSource, ingressSessionId, stateBucketName, } from "./plan.js";
|
|
5
5
|
import { zipSingleFile } from "./zip.js";
|
|
6
|
+
/** How long the post-deploy probe waits for the fresh session (image pull + microVM boot + snapshot
|
|
7
|
+
* restore + channel construction) before gating with the last answer. */
|
|
8
|
+
const PROBE_TIMEOUT_MS = 120_000;
|
|
9
|
+
const PROBE_INTERVAL_MS = 3_000;
|
|
10
|
+
/**
|
|
11
|
+
* Drive the forwarder's reserved `/__fastagent/probe` path until it answers, and read the runtime's
|
|
12
|
+
* STRUCTURED verdict. The path answers on every forwarder topology (a schedule-only URL refuses
|
|
13
|
+
* ordinary public traffic, so a plain `GET /health` would 404 there), and the verdict rides a
|
|
14
|
+
* transport-200 JSON body `{ ok, error? }` — the ordinary webhook relay folds a non-200 transport
|
|
15
|
+
* into an opaque 502, which would strip the very diagnostics this probe exists to carry.
|
|
16
|
+
*
|
|
17
|
+
* Outcome policy: `ok:true` verifies the deploy; `ok:false` gates IMMEDIATELY with the runtime's own
|
|
18
|
+
* error text (construction rejections are cached per session, so polling cannot change the answer);
|
|
19
|
+
* anything else (unroutable URL, forwarder 4xx/5xx, malformed body) is retried to the deadline —
|
|
20
|
+
* that budget's job is absorbing cold-start provisioning — and then gates with the last answer seen.
|
|
21
|
+
*/
|
|
22
|
+
async function probeRuntime(probeUrl, auth, fetchImpl, timeoutMs = PROBE_TIMEOUT_MS, intervalMs = PROBE_INTERVAL_MS) {
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
let last;
|
|
25
|
+
for (;;) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetchImpl(probeUrl, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ auth }),
|
|
31
|
+
signal: AbortSignal.timeout(65_000),
|
|
32
|
+
});
|
|
33
|
+
const bodyText = await res.text();
|
|
34
|
+
if (res.status === 200) {
|
|
35
|
+
let verdict;
|
|
36
|
+
try {
|
|
37
|
+
verdict = JSON.parse(bodyText);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* malformed — fall through to retry with it as the last answer */
|
|
41
|
+
}
|
|
42
|
+
if (verdict?.ok === true)
|
|
43
|
+
return { ok: true };
|
|
44
|
+
if (verdict?.ok === false) {
|
|
45
|
+
const error = typeof verdict.error === "string" ? verdict.error : "unknown error";
|
|
46
|
+
return { ok: false, gate: `the deployed runtime failed its probe: ${error} — fix and re-run` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const firstLine = bodyText.trim().split("\n")[0] ?? "";
|
|
50
|
+
last = `${res.status}${firstLine ? ` ${firstLine}` : ""}`;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* not routable yet (Function URL DNS, cold start) — keep polling until the deadline */
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() >= deadline) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
gate: last
|
|
59
|
+
? `the forwarder probe never verified the deployment (last answer: ${last}) — check the runtime logs and re-run`
|
|
60
|
+
: "the forwarder URL never answered the probe — check the Function URL / runtime logs and re-run",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
6
66
|
/** Stack outputs (`describe-stacks --query "Stacks[0].Outputs"`) → { OutputKey: OutputValue }. */
|
|
7
67
|
export function parseStackOutputs(stdout) {
|
|
8
68
|
try {
|
|
@@ -63,7 +123,9 @@ export function parseCheckpointReply(stdout) {
|
|
|
63
123
|
* post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
|
|
64
124
|
* gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
|
|
65
125
|
*/
|
|
66
|
-
export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack
|
|
126
|
+
export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack,
|
|
127
|
+
/** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
|
|
128
|
+
probe = {}) {
|
|
67
129
|
const gate = (g) => ({ ok: false, gate: g });
|
|
68
130
|
const stack = `fastagent-${plan.name}`;
|
|
69
131
|
const repo = `fastagent/${plan.name}`;
|
|
@@ -359,19 +421,42 @@ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile
|
|
|
359
421
|
log("note: no ingress session to stop (first deploy, or already reclaimed)");
|
|
360
422
|
}
|
|
361
423
|
else {
|
|
362
|
-
|
|
363
|
-
|
|
424
|
+
// A GATE, not a warning: the probe below reaches the SAME fixed session id, so a session
|
|
425
|
+
// still running the previous image would answer it and the deploy would claim to have
|
|
426
|
+
// verified a serving path it never touched. Unable to guarantee the session is fresh =
|
|
427
|
+
// unable to verify = stop.
|
|
364
428
|
const firstLine = stderr.trim().split("\n")[0];
|
|
365
|
-
|
|
366
|
-
|
|
429
|
+
return gate(`could not stop the ingress session — it may still be serving the PREVIOUS image, so the ` +
|
|
430
|
+
`deploy cannot verify the new one${firstLine ? ` (${firstLine})` : ""}. ` +
|
|
431
|
+
`Stop it manually (aws ${stopCommand.join(" ")}) and re-run`);
|
|
367
432
|
}
|
|
368
433
|
}
|
|
369
434
|
}
|
|
435
|
+
// 8c. Every forwarder topology MUST carry the ForwarderUrl output — schedule-only and
|
|
436
|
+
// selfSchedule-only deployments included, since the probe below is their only construction
|
|
437
|
+
// check (there is no boot-time failStartup on this host). A missing output means an edited
|
|
438
|
+
// template; skipping the probe silently would let such a deploy report success unverified.
|
|
439
|
+
// Only a pure-invoke deployment (no forwarder) legitimately has no URL and nothing to probe.
|
|
440
|
+
// `channels.length` is belt-and-braces: the planner derives needsForwarder FROM the channel
|
|
441
|
+
// list, but this gate must not silently trust that invariant across callers.
|
|
442
|
+
if ((plan.needsForwarder || plan.channels.length > 0) && !url) {
|
|
443
|
+
return gate("this deployment needs the forwarder but the stack has no ForwarderUrl output — regenerate the " +
|
|
444
|
+
"template with --force");
|
|
445
|
+
}
|
|
446
|
+
// 8d. Warm + verify the NEW serving path end to end, BEFORE registration: the probe wakes a fresh
|
|
447
|
+
// session on the new image through the forwarder's reserved path, which restores the state
|
|
448
|
+
// snapshot and constructs the channels — construction is deferred to exactly that moment
|
|
449
|
+
// (channels/agentcore.ts), so this is where a bad credential, a broken channels/ module, or an
|
|
450
|
+
// unrestorable snapshot surfaces AT DEPLOY TIME with the runtime's own error text.
|
|
451
|
+
if (url) {
|
|
452
|
+
log("probing the deployed runtime (state restore + channel construction)…");
|
|
453
|
+
const verdict = await probeRuntime(`${url}/__fastagent/probe`, plan.secrets.FASTAGENT_INGRESS_SECRET ?? "", probe.fetchImpl ?? fetch, probe.timeoutMs, probe.intervalMs);
|
|
454
|
+
if (!verdict.ok)
|
|
455
|
+
return gate(verdict.gate);
|
|
456
|
+
log("runtime verified (state restored, channels constructed)");
|
|
457
|
+
}
|
|
370
458
|
// 9. Post-deploy webhook registration — same registrar seam as every host, pointed at the
|
|
371
459
|
// forwarder's Function URL. Gate policy is the shared registration-gate kernel.
|
|
372
|
-
if (plan.channels.length > 0 && !url) {
|
|
373
|
-
return gate("channels are declared but the stack has no ForwarderUrl output — regenerate the template with --force");
|
|
374
|
-
}
|
|
375
460
|
const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
|
|
376
461
|
if (url) {
|
|
377
462
|
if (plan.channels.includes("telegram")) {
|
package/dist/deploy/preflight.js
CHANGED
|
@@ -195,12 +195,17 @@ export async function preflightDeploy(input) {
|
|
|
195
195
|
const rel = relative(workspace, p);
|
|
196
196
|
return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
|
|
197
197
|
};
|
|
198
|
-
// The secrets DIR is the unit,
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
198
|
+
// The secrets DIR is the unit of RESPONSIBILITY, but never the unit of the leak QUESTION below: the
|
|
199
|
+
// generated ignore excludes the dir's CONTENTS (`**/.secrets/**`) so its two value-free tracked
|
|
200
|
+
// scaffolds can be re-included, and a directory-level question reads that correct file as "not
|
|
201
|
+
// excluded" — the generator's own default output gated its own deploy (field-hit: a fresh
|
|
202
|
+
// kit-layout workspace without --force; --force skips checking our own file, which is why the
|
|
203
|
+
// combination stayed invisible). What leaks is a FILE, so files are what the gate asks about — see
|
|
204
|
+
// secretDirFiles below, which enumerates what is actually inside (an atomic-write temp beside
|
|
205
|
+
// auth.json, a second key file, an editor backup of `.env`: the dir-as-unit worry, covered per
|
|
206
|
+
// file). The auth path adds an entry only when an override puts it OUTSIDE that dir. An external
|
|
207
|
+
// secrets dir (the deployed posture: a mounted volume) is outside the context — nothing to check,
|
|
208
|
+
// nothing to exclude.
|
|
204
209
|
const secretsRel = inContext(resolveSecretsDir(agentDir));
|
|
205
210
|
const authRel = inContext(authPath);
|
|
206
211
|
const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
|
|
@@ -246,7 +251,29 @@ export async function preflightDeploy(input) {
|
|
|
246
251
|
.map((n) => join(relDir, n).split(sep).join("/"));
|
|
247
252
|
};
|
|
248
253
|
const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
|
|
249
|
-
|
|
254
|
+
// Everything ACTUALLY inside the secrets dir, minus the two tracked scaffolds the image ships on
|
|
255
|
+
// purpose (they carry no values; the generated ignore re-includes them by name). Existence is the
|
|
256
|
+
// enumeration itself — readdir lists exactly what could be baked — and a hand-written ignore that
|
|
257
|
+
// misses the dir now gates NAMING the leaking file, a better diagnostic than pointing at a
|
|
258
|
+
// directory. Recurses: a subdirectory inside .secrets is unusual but its files leak all the same.
|
|
259
|
+
const secretDirFiles = async (dirRel) => {
|
|
260
|
+
const entries = await readdir(join(workspace, dirRel), { withFileTypes: true }).catch(() => []);
|
|
261
|
+
const files = [];
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (entry.name === ".gitignore" || entry.name === ".env.example")
|
|
264
|
+
continue;
|
|
265
|
+
if (entry.isDirectory())
|
|
266
|
+
files.push(...(await secretDirFiles(`${dirRel}/${entry.name}`)));
|
|
267
|
+
else
|
|
268
|
+
files.push(`${dirRel}/${entry.name}`);
|
|
269
|
+
}
|
|
270
|
+
return files;
|
|
271
|
+
};
|
|
272
|
+
const leakCandidates = [
|
|
273
|
+
...(secretsRel ? await secretDirFiles(secretsRel) : []),
|
|
274
|
+
...(await present(authElsewhere && authRel !== undefined ? [authRel] : [])),
|
|
275
|
+
...envFiles,
|
|
276
|
+
];
|
|
250
277
|
// Same existence rule: a node_modules that is not there cannot be uploaded.
|
|
251
278
|
const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
|
|
252
279
|
const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
|
|
@@ -19,7 +19,7 @@ import { resolveSecretsDir } from "../../paths.js";
|
|
|
19
19
|
import { loadAgentDefinition } from "./definition.js";
|
|
20
20
|
import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
|
|
21
21
|
import { createPiModels } from "./models.js";
|
|
22
|
-
import {
|
|
22
|
+
import { reportFindingsIfChanged } from "./report.js";
|
|
23
23
|
import { inMemorySessionStore } from "./sessions.js";
|
|
24
24
|
import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
|
|
25
25
|
import { withSearchTool } from "./search-tools.js";
|
|
@@ -221,12 +221,6 @@ export function createPiAgent(options) {
|
|
|
221
221
|
observer: options.observer,
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
|
-
/** Stable identity of a definition's non-fatal findings, for change-detection in `live` (dedup only). */
|
|
225
|
-
function findingsSignature(def) {
|
|
226
|
-
const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
|
|
227
|
-
const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
|
|
228
|
-
return [...collisions, ...diagnostics].sort().join("\n");
|
|
229
|
-
}
|
|
230
224
|
/**
|
|
231
225
|
* L2: "point at a directory → agent": load + assemble (base + AGENTS.md + skills + env) + L1 in one
|
|
232
226
|
* call. Returns the definition so callers can surface diagnostics/collisions.
|
|
@@ -239,10 +233,11 @@ export async function createPiAgentFromDefinition(dir, options) {
|
|
|
239
233
|
// Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to
|
|
240
234
|
// report (skills/diagnostics/collisions). Serving does NOT close over it — see `live` below.
|
|
241
235
|
const definition = await loadAgentDefinition(dir, { cwd: env.cwd, env });
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
236
|
+
// Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by
|
|
237
|
+
// the resolved dir): announced once here, and re-announced by a turn or by the control plane's
|
|
238
|
+
// command list only when the set CHANGES — a runtime-written bad skill surfaces the moment it
|
|
239
|
+
// appears, a static one does not spam. Log dedup, not session state (stateless invoke holds).
|
|
240
|
+
reportFindingsIfChanged(definition.dir, definition);
|
|
246
241
|
// Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
|
|
247
242
|
// it; a caller's own search_tools wins).
|
|
248
243
|
const tools = withSearchTool(options.tools ?? piDefaultTools());
|
|
@@ -265,11 +260,7 @@ export async function createPiAgentFromDefinition(dir, options) {
|
|
|
265
260
|
// next good edit heals both.
|
|
266
261
|
live: async () => {
|
|
267
262
|
const def = await loadAgentDefinition(dir, { cwd: env.cwd, env });
|
|
268
|
-
|
|
269
|
-
if (sig !== reportedFindings) {
|
|
270
|
-
reportedFindings = sig;
|
|
271
|
-
reportDefinitionWarnings(def.collisions, def.diagnostics);
|
|
272
|
-
}
|
|
263
|
+
reportFindingsIfChanged(def.dir, def);
|
|
273
264
|
return {
|
|
274
265
|
systemPrompt: assembleSystemPrompt({
|
|
275
266
|
// Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity,
|
|
@@ -41,6 +41,21 @@ export interface LoadAgentDefinitionOptions {
|
|
|
41
41
|
}
|
|
42
42
|
/** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
|
|
43
43
|
export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
|
|
44
|
+
/**
|
|
45
|
+
* The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
|
|
46
|
+
* loader, same containment guard, same first-wins collision rule) — for readers that need only the
|
|
47
|
+
* names and must not pay the full load's ② context walk (every AGENTS.md from cwd to root) for them.
|
|
48
|
+
* The control plane's `commands()` is that reader, called when a composer opens its completion list.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadAgentSkills(agentDir: string, options?: {
|
|
51
|
+
cwd?: string;
|
|
52
|
+
env?: ExecutionEnv;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
skills: Skill[];
|
|
55
|
+
diagnostics: SkillDiagnostic[];
|
|
56
|
+
collisions: SkillCollision[];
|
|
57
|
+
dir: string;
|
|
58
|
+
}>;
|
|
44
59
|
/**
|
|
45
60
|
* Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
|
|
46
61
|
* this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
|
|
@@ -42,6 +42,11 @@ export async function loadAgentDefinition(agentDir, options = {}) {
|
|
|
42
42
|
throw new Error(`cannot read ${personaPath}: ${personaRead.error.message}`);
|
|
43
43
|
}
|
|
44
44
|
const persona = personaRead.ok ? personaRead.value : undefined;
|
|
45
|
+
const { skills, diagnostics, collisions } = await readSkills(e, root);
|
|
46
|
+
return { contextFiles, persona, skills, diagnostics, collisions, dir: root };
|
|
47
|
+
}
|
|
48
|
+
/** The skills half, shared by the full load and {@link loadAgentSkills}. `root` is already resolved. */
|
|
49
|
+
async function readSkills(e, root) {
|
|
45
50
|
// Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
|
|
46
51
|
// definition loads the same skills on every machine — and, like tools/channels/schedules, a symlink
|
|
47
52
|
// that escapes the agent dir is refused rather than followed (the fourth of four surfaces).
|
|
@@ -58,7 +63,23 @@ export async function loadAgentDefinition(agentDir, options = {}) {
|
|
|
58
63
|
byName.set(skill.name, skill);
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
|
-
return {
|
|
66
|
+
return { skills: [...byName.values()], diagnostics, collisions };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
|
|
70
|
+
* loader, same containment guard, same first-wins collision rule) — for readers that need only the
|
|
71
|
+
* names and must not pay the full load's ② context walk (every AGENTS.md from cwd to root) for them.
|
|
72
|
+
* The control plane's `commands()` is that reader, called when a composer opens its completion list.
|
|
73
|
+
*/
|
|
74
|
+
export async function loadAgentSkills(agentDir, options = {}) {
|
|
75
|
+
const cwd = options.cwd ?? agentDir;
|
|
76
|
+
const e = options.env ?? new NodeExecutionEnv({ cwd });
|
|
77
|
+
const rootResult = await e.absolutePath(agentDir);
|
|
78
|
+
if (!rootResult.ok)
|
|
79
|
+
throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
|
|
80
|
+
// `dir` is the RESOLVED root, like {@link LoadedDefinition.dir}: readers key per-definition state
|
|
81
|
+
// (the findings memo) on it, and "./agent" vs an absolute path must not become two definitions.
|
|
82
|
+
return { ...(await readSkills(e, rootResult.value)), dir: rootResult.value };
|
|
62
83
|
}
|
|
63
84
|
/**
|
|
64
85
|
* Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
|
|
@@ -4,7 +4,7 @@ import type { SessionControl } from "../../session.ts";
|
|
|
4
4
|
import type { SessionObserver } from "./invoke.ts";
|
|
5
5
|
import type { PiSessionReader, PiSessionStore } from "./sessions.ts";
|
|
6
6
|
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
7
|
-
import type
|
|
7
|
+
import { type LoadedDefinition } from "./definition.ts";
|
|
8
8
|
import type { ToolCollision } from "./tool.ts";
|
|
9
9
|
import type { MountedTool } from "./tool.ts";
|
|
10
10
|
export interface CreatePiAgentFromDirOptions {
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -14,6 +14,8 @@ import { resolveStateRoot, resolvePlacement } from "../../paths.js";
|
|
|
14
14
|
import { createPiAgentFromDefinition, resolveAgentTools } from "./create.js";
|
|
15
15
|
import { createPiSessionControl } from "./session-control.js";
|
|
16
16
|
import { withWakeTool } from "./wake-tool.js";
|
|
17
|
+
import { loadAgentSkills } from "./definition.js";
|
|
18
|
+
import { reportFindingsIfChanged } from "./report.js";
|
|
17
19
|
import { jsonlSessionStore } from "./sessions.js";
|
|
18
20
|
export async function resolveAgentAssembly(dir, options = {}) {
|
|
19
21
|
// Placement is structural (resolvePlacement): the AGENT DIR carries definition + config + machinery;
|
|
@@ -73,6 +75,23 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
73
75
|
? createPiSessionControl({
|
|
74
76
|
sessions,
|
|
75
77
|
boundary: () => boundaryParts,
|
|
78
|
+
// Skills ARE the names a client offers — the resolved set, after collisions were decided
|
|
79
|
+
// first-wins, which a client cannot reconstruct from the directory. Read LIVE (the directory
|
|
80
|
+
// is the agent: a skill added while serving is in play on the next turn, so it must be
|
|
81
|
+
// listable now) and SKILLS-ONLY: this is called when a composer opens its completion list,
|
|
82
|
+
// and the full load's ② context walk buys nothing here.
|
|
83
|
+
commands: async () => {
|
|
84
|
+
const loaded = await loadAgentSkills(agentDir, { cwd: workspace });
|
|
85
|
+
// A skill whose frontmatter broke simply is not in `skills` — it would disappear from the
|
|
86
|
+
// author's composer with no signal anywhere. The memo is SHARED with the turn path (keyed
|
|
87
|
+
// by dir), so a finding is warned when it appears, not once per reader that notices it.
|
|
88
|
+
reportFindingsIfChanged(loaded.dir, loaded);
|
|
89
|
+
return loaded.skills.map((skill) => ({
|
|
90
|
+
name: skill.name,
|
|
91
|
+
description: skill.description,
|
|
92
|
+
source: "skill",
|
|
93
|
+
}));
|
|
94
|
+
},
|
|
76
95
|
// The caller tap's boundary-event half: state_changed/compaction_* originate in the hub
|
|
77
96
|
// and never cross the data plane's observer seam — without this, an audit tap wired here
|
|
78
97
|
// would miss exactly the mutations it most needs to see (set_model).
|
|
@@ -6,8 +6,24 @@ import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
|
6
6
|
import type { SkillCollision } from "./definition.ts";
|
|
7
7
|
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
8
8
|
import type { ToolCollision } from "./tool.ts";
|
|
9
|
+
type Findings = {
|
|
10
|
+
collisions: SkillCollision[];
|
|
11
|
+
diagnostics: SkillDiagnostic[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* THE door for definition findings: warns only when this dir's set CHANGED since the last report.
|
|
15
|
+
* Every reader calls it — boot, the per-turn live read, the control plane's command list — so a
|
|
16
|
+
* finding is announced when it appears and never repeated. There is deliberately no "record without
|
|
17
|
+
* printing" variant: a memo entry that trusts some other caller to have printed would silently
|
|
18
|
+
* swallow findings for a caller that does not.
|
|
19
|
+
*
|
|
20
|
+
* `dir` must be the RESOLVED definition root (`LoadedDefinition.dir`), or two spellings of one path
|
|
21
|
+
* become two memos and warn twice.
|
|
22
|
+
*/
|
|
23
|
+
export declare function reportFindingsIfChanged(dir: string, def: Findings): void;
|
|
9
24
|
export declare function reportDefinitionWarnings(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
|
|
10
25
|
export declare function reportToolCollisions(collisions: ToolCollision[]): void;
|
|
11
26
|
/** Report per-file module failures. The caller decides whether they are degradations (tools/schedules)
|
|
12
27
|
* or fatal (declared channels on the serving path). */
|
|
13
28
|
export declare function reportModuleLoadFailures(failures: ModuleLoadFailure[]): void;
|
|
29
|
+
export {};
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import { log } from "../../log.js";
|
|
2
|
+
/** Stable identity of a definition's non-fatal findings — the dedup key below. */
|
|
3
|
+
function findingsSignature(def) {
|
|
4
|
+
const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
|
|
5
|
+
const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
|
|
6
|
+
return [...collisions, ...diagnostics].sort().join("\n");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The last reported finding set PER DEFINITION DIR. One memo for every reader of a definition (the
|
|
10
|
+
* per-turn live read, the control plane's command list), because the thing being deduped is the
|
|
11
|
+
* FINDING, not the reader: a newly broken skill deserves one warning, not one per code path that
|
|
12
|
+
* noticed it, and a static one deserves none at all.
|
|
13
|
+
*/
|
|
14
|
+
const lastFindings = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* THE door for definition findings: warns only when this dir's set CHANGED since the last report.
|
|
17
|
+
* Every reader calls it — boot, the per-turn live read, the control plane's command list — so a
|
|
18
|
+
* finding is announced when it appears and never repeated. There is deliberately no "record without
|
|
19
|
+
* printing" variant: a memo entry that trusts some other caller to have printed would silently
|
|
20
|
+
* swallow findings for a caller that does not.
|
|
21
|
+
*
|
|
22
|
+
* `dir` must be the RESOLVED definition root (`LoadedDefinition.dir`), or two spellings of one path
|
|
23
|
+
* become two memos and warn twice.
|
|
24
|
+
*/
|
|
25
|
+
export function reportFindingsIfChanged(dir, def) {
|
|
26
|
+
const sig = findingsSignature(def);
|
|
27
|
+
if (lastFindings.get(dir) === sig)
|
|
28
|
+
return;
|
|
29
|
+
lastFindings.set(dir, sig);
|
|
30
|
+
reportDefinitionWarnings(def.collisions, def.diagnostics);
|
|
31
|
+
}
|
|
2
32
|
export function reportDefinitionWarnings(collisions, diagnostics) {
|
|
3
33
|
for (const c of collisions) {
|
|
4
34
|
log.warn(`[fastagent] skill "${c.name}" collision — using ${c.winnerPath}, ignoring ${c.loserPath}`);
|
|
@@ -37,7 +37,7 @@ import { canonicalPath, loadAgentDefinition } from "./definition.js";
|
|
|
37
37
|
import { createPiModelRuntime, probeAuthSource } from "./models.js";
|
|
38
38
|
import { log } from "../../log.js";
|
|
39
39
|
import { additiveActivation, turnContext } from "./tool-context.js";
|
|
40
|
-
import {
|
|
40
|
+
import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
|
|
41
41
|
import { resolveAgentAssembly } from "./open.js";
|
|
42
42
|
/** Adapt coding-agent's resident SessionManager to FastAgent's shared tool-runtime manager port. */
|
|
43
43
|
function toolChatSessionManager(session) {
|
|
@@ -119,7 +119,7 @@ sessionManager) {
|
|
|
119
119
|
const model = resolveModel(modelRuntime, modelSpec);
|
|
120
120
|
const env = new NodeExecutionEnv({ cwd });
|
|
121
121
|
const definition = await loadAgentDefinition(agentDir, { cwd, env });
|
|
122
|
-
|
|
122
|
+
reportFindingsIfChanged(definition.dir, definition);
|
|
123
123
|
const defaultNames = piDefaultTools().map((t) => t.name);
|
|
124
124
|
const customTools = tools.filter((t) => !defaultNames.includes(t.name));
|
|
125
125
|
// Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Models } from "@earendil-works/pi-ai";
|
|
3
|
-
import { type SessionControl, type SessionEvent } from "../../session.ts";
|
|
3
|
+
import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
|
|
4
4
|
import type { Lease, SessionObserver } from "./invoke.ts";
|
|
5
5
|
import { type AnyModel, type PiHarnessFactory } from "./harness.ts";
|
|
6
6
|
import { type PiSessionReader } from "./sessions.ts";
|
|
@@ -39,6 +39,16 @@ export interface CreatePiSessionControlOptions {
|
|
|
39
39
|
* (assembly completes before any dispatch can arrive). Absent / undefined → boundary commands
|
|
40
40
|
* are gated off in `capabilities()` and rejected `unsupported_capability`. */
|
|
41
41
|
boundary?: () => PiBoundaryWiring | undefined;
|
|
42
|
+
/** The definition's names, as a LAZY thunk for the same reason {@link boundary} is one (the hub
|
|
43
|
+
* exists before the assembly that can read a definition) — and async because the definition is
|
|
44
|
+
* live: this must re-read it, not close over a boot snapshot, or `commands()` would advertise a
|
|
45
|
+
* list the next turn no longer runs.
|
|
46
|
+
*
|
|
47
|
+
* OPTIONAL because absence is a TRUE answer for the assembly that omits it: a hub over an L1
|
|
48
|
+
* agent (`createPiAgent({ model, instructions, tools })`) has no definition and therefore no
|
|
49
|
+
* names, and `[]` says exactly that. Wire it whenever the agent came from a DIRECTORY — there
|
|
50
|
+
* `[]` would be a lie; the directory constructor (`createPiAgentFromDir`) always does. */
|
|
51
|
+
commands?: () => Promise<AgentCommand[]>;
|
|
42
52
|
/** Tap for the events the HUB ITSELF generates (boundary mutations: `state_changed`,
|
|
43
53
|
* `compaction_*`) — those never pass through the data plane's observer seam, so a consumer
|
|
44
54
|
* composing a full-vocabulary tap wires the run events via the observer AND this. Called after
|
|
@@ -200,6 +200,9 @@ export function createPiSessionControl(options) {
|
|
|
200
200
|
fanOut(session, event);
|
|
201
201
|
};
|
|
202
202
|
const control = {
|
|
203
|
+
async commands() {
|
|
204
|
+
return (await options.commands?.()) ?? [];
|
|
205
|
+
},
|
|
203
206
|
capabilities: () => {
|
|
204
207
|
const b = boundary?.();
|
|
205
208
|
return {
|