@fastagent-sh/fastagent 0.16.1 → 0.17.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.
Files changed (40) hide show
  1. package/dist/channels/agentcore-state.js +7 -0
  2. package/dist/channels/agentcore.js +24 -5
  3. package/dist/channels/control.d.ts +1 -1
  4. package/dist/channels/control.js +5 -1
  5. package/dist/cli/commands/attach.d.ts +29 -1
  6. package/dist/cli/commands/attach.js +76 -4
  7. package/dist/cli/commands/deploy.js +11 -5
  8. package/dist/cli/commands/dev.js +2 -2
  9. package/dist/cli/commands/info.js +2 -2
  10. package/dist/cli/commands/logs.d.ts +6 -0
  11. package/dist/cli/commands/logs.js +27 -0
  12. package/dist/cli/commands/start.js +2 -2
  13. package/dist/cli/program.js +26 -0
  14. package/dist/deploy/agentcore/logs.d.ts +35 -0
  15. package/dist/deploy/agentcore/logs.js +112 -0
  16. package/dist/deploy/agentcore/plan.d.ts +26 -2
  17. package/dist/deploy/agentcore/plan.js +45 -6
  18. package/dist/deploy/container.js +6 -3
  19. package/dist/engines/pi/config.d.ts +1 -1
  20. package/dist/engines/pi/config.js +1 -1
  21. package/dist/engines/pi/create.d.ts +6 -1
  22. package/dist/engines/pi/create.js +16 -19
  23. package/dist/engines/pi/definition.d.ts +15 -0
  24. package/dist/engines/pi/definition.js +22 -1
  25. package/dist/engines/pi/harness.d.ts +12 -28
  26. package/dist/engines/pi/harness.js +21 -71
  27. package/dist/engines/pi/open.d.ts +1 -1
  28. package/dist/engines/pi/open.js +20 -0
  29. package/dist/engines/pi/report.d.ts +16 -0
  30. package/dist/engines/pi/report.js +30 -0
  31. package/dist/engines/pi/session-builder.js +2 -2
  32. package/dist/engines/pi/session-control.d.ts +23 -4
  33. package/dist/engines/pi/session-control.js +159 -27
  34. package/dist/engines/pi/session-settings.d.ts +51 -0
  35. package/dist/engines/pi/session-settings.js +73 -0
  36. package/dist/engines/pi/sessions.d.ts +21 -7
  37. package/dist/engines/pi/sessions.js +43 -0
  38. package/dist/session-remote.js +17 -0
  39. package/dist/session.d.ts +54 -9
  40. package/package.json +1 -1
@@ -41,6 +41,13 @@ const PUT_TIMEOUT_MS = 60_000;
41
41
  * file from the builder machine, but the box's own copy is the one that has been REFRESHED, and this
42
42
  * snapshot is its volume: the same rule every other host states ("a credential already refreshed on
43
43
  * the volume is never overwritten"). The seed is bootstrap for a snapshot that has none.
44
+ *
45
+ * That sentence is only true because the generated template points FASTAGENT_SECRETS_DIR INSIDE the
46
+ * state root (deploy/agentcore/plan.ts `SECRETS_DIR`) — nothing here special-cases credentials, the
47
+ * walk below simply reaches them. Moving the secrets dir back out (e.g. to the sibling layout the
48
+ * volume-backed hosts use) silently un-does it: the file stops being copied, the platform wipes it
49
+ * with the microVM, and a single-use OAuth refresh token dies with it. `restores VERBATIM` is a
50
+ * statement about where that directory is, not a property this module can enforce on its own.
44
51
  */
45
52
  const EXCLUDED = new Set(["control.json"]);
46
53
  /** Every regular file under `root`, as root-relative POSIX paths (stable across platforms). */
@@ -19,7 +19,8 @@
19
19
  * - `{ kind: "invoke", session, text }` — the programmatic data plane; streams the invoke back as
20
20
  * SSE (AgentCore's streaming response form), reusing the HTTP channel's handler wholesale.
21
21
  *
22
- * `/ping` reports `HealthyBusy` while process-wide background work is in flight (busy.ts) — webhook
22
+ * `/ping` reports `HealthyBusy` (+ `time_of_last_update`, required see the handler) while
23
+ * process-wide background work is in flight (busy.ts) — webhook
23
24
  * channels ACK fast and run turns fire-and-forget, and AgentCore ends an idle session, so without
24
25
  * this signal a long turn would be killed mid-flight right after its ACK. `Healthy` when idle lets
25
26
  * the platform reclaim the microVM (that idle-to-zero IS the point of this deployment).
@@ -242,12 +243,30 @@ export function agentcoreRoutes(options) {
242
243
  stateSync.save();
243
244
  return response;
244
245
  };
246
+ // The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
247
+ // (background turns in flight). `time_of_last_update` is REQUIRED for the keep-alive to work,
248
+ // despite the contract documenting it as optional ("If you omit the field, the platform tracks
249
+ // status changes on its own"): measured on a live Runtime (us-east-1, 2026-08-04), the platform's
250
+ // idle measurement reads ONLY this field — with it omitted, a session polling every ~2s and
251
+ // receiving HealthyBusy 200s was still reclaimed at exactly IdleRuntimeSessionTimeout after the
252
+ // last InvokeAgentRuntime, mid-turn, 2s after the last HealthyBusy answer; with the field present
253
+ // the same turn survived 3.5× the idle timeout with zero invocations and completed. The value
254
+ // updates ONLY on a real status change: a timestamp advancing on every ping declares a perpetual
255
+ // status change, so the idle timeout never fires and dead-idle sessions live to MaxLifetime
256
+ // (quota exhaustion — the failure mode the contract's warning describes).
257
+ let lastStatus = "Healthy";
258
+ let lastTransition = Math.floor(Date.now() / 1000);
245
259
  return {
246
260
  "POST /invocations": invocations,
247
- // The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
248
- // (background turns in flight). No time_of_last_update the platform tracks status changes
249
- // itself, and a timestamp advancing every ping would defeat the idle timeout (their docs warn).
250
- "GET /ping": () => json({ status: isBusy() ? "HealthyBusy" : "Healthy" }, 200),
261
+ "GET /ping": () => {
262
+ const status = isBusy() ? "HealthyBusy" : "Healthy";
263
+ if (status !== lastStatus) {
264
+ lastTransition = Math.floor(Date.now() / 1000);
265
+ log.debug(`[agentcore] ping status: ${lastStatus} → ${status}`);
266
+ lastStatus = status;
267
+ }
268
+ return json({ status, time_of_last_update: lastTransition }, 200);
269
+ },
251
270
  };
252
271
  }
253
272
  /** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
@@ -22,7 +22,7 @@ export interface ControlRoutesOptions {
22
22
  agent?: Agent;
23
23
  }
24
24
  /**
25
- * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
25
+ * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
26
26
  * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
27
27
  */
28
28
  export declare function controlRoutes(control: SessionControl, options: ControlRoutesOptions): Routes;
@@ -25,6 +25,7 @@ function parseWireCommand(raw) {
25
25
  compact: true,
26
26
  set_model: true,
27
27
  set_thinking: true,
28
+ navigate: true,
28
29
  };
29
30
  void _commandDriftGuard;
30
31
  if (typeof raw !== "object" || raw === null)
@@ -76,12 +77,14 @@ function parseWireCommand(raw) {
76
77
  return typeof c.model === "string" ? { type: "set_model", model: c.model } : undefined;
77
78
  case "set_thinking":
78
79
  return typeof c.level === "string" ? { type: "set_thinking", level: c.level } : undefined;
80
+ case "navigate":
81
+ return typeof c.targetId === "string" ? { type: "navigate", targetId: c.targetId } : undefined;
79
82
  default:
80
83
  return undefined;
81
84
  }
82
85
  }
83
86
  /**
84
- * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
87
+ * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
85
88
  * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
86
89
  */
87
90
  export function controlRoutes(control, options) {
@@ -108,6 +111,7 @@ export function controlRoutes(control, options) {
108
111
  return {
109
112
  ...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
110
113
  "GET /control/capabilities": guard(() => json(control.capabilities())),
114
+ "GET /control/commands": guard(async () => json(await control.commands())),
111
115
  "GET /control/state": guard(async (_req, url) => {
112
116
  const session = sessionParam(url);
113
117
  if (!session)
@@ -1,4 +1,4 @@
1
- import type { SessionControl } from "../../session.ts";
1
+ import type { SessionControl, SessionEntry } from "../../session.ts";
2
2
  export interface AttachOptions {
3
3
  /** Override the control endpoint (skip control.json discovery) — for a remote serve. */
4
4
  url?: string;
@@ -62,6 +62,34 @@ export interface AttachIo {
62
62
  write: (chunk: string) => void;
63
63
  warn: (line: string) => void;
64
64
  }
65
+ /**
66
+ * The backfill slice REDUCED TO THE ACTIVE PATH — a session is a tree (a `navigate`, or a
67
+ * compaction, leaves sibling branches behind), and printing the slice raw renders the abandoned
68
+ * branch interleaved with the live one as if it were one conversation. Reachability is computed
69
+ * backwards from the leaf; an entry whose parent lies BEFORE the slice is path-connected by
70
+ * construction (its ancestors were rendered in an earlier round), so only what the walk cannot
71
+ * reach is dropped. An engine that reports NO leaf says nothing about branches, so its slice stands
72
+ * whole — the one case where "unknown" must not read as "off-path".
73
+ */
74
+ export declare function activePathSlice(entries: SessionEntry[], leafEntryId: string | undefined): SessionEntry[];
75
+ /**
76
+ * Answer a RESERVED-SLASH line (anything starting with `/` that is not `/abort`). Two intents share
77
+ * the prefix — a mistyped control command and an attempt to invoke a name — and they are answered
78
+ * differently:
79
+ *
80
+ * - a mistyped slash gets the certain half NOW (a leading `/` is reserved here, whatever the token
81
+ * turns out to be), because waiting on a remote read that can be slow or fail would leave the
82
+ * input unanswered; it deliberately does not pre-judge the token as unknown — the read may be
83
+ * about to prove it names a real skill;
84
+ * - `/commands` prints nothing first: its whole answer IS the read, and a placeholder is noise;
85
+ * - the enumeration answers `/commands` ONLY — dumping every skill at a mistyped `/aboort` answers
86
+ * an intent the typo did not express.
87
+ *
88
+ * Names print BARE: this composer cannot expand `/name` (the data plane takes prompts as text), so
89
+ * printing them with a slash would invite the user straight back into this branch. Returns the
90
+ * promise for the remote half, so a caller (or a test) can await the second line.
91
+ */
92
+ export declare function answerSlashInput(trimmed: string, control: Pick<SessionControl, "commands">, println: (line: string) => void): Promise<void>;
65
93
  /**
66
94
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
67
95
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -185,7 +185,7 @@ export async function runAttach(sessionArg, dirArg, opts) {
185
185
  // invoke) — give the human a corrective signal.
186
186
  log.warn(`[fastagent] no durable record for "${sessionArg}" yet — a new session, or a typo?`);
187
187
  }
188
- log.info(`[fastagent] type to steer the active run; /abort to stop it; Ctrl+C to detach`);
188
+ log.info(`[fastagent] type to steer the active run; /abort to stop it; /commands to list what this agent defines; Ctrl+C to detach`);
189
189
  // stdin → the two planes: a line steers the ACTIVE run; with no run to join (no_active_run) it
190
190
  // falls back to STARTING one over the remote data plane (`POST /control/invoke`) — try-steer-
191
191
  // then-prompt avoids a state() pre-check race. Acceptance is not outcome: rejections print and
@@ -222,7 +222,7 @@ export async function runAttach(sessionArg, dirArg, opts) {
222
222
  // `/` is a reserved command prefix: a typo'd /aboort silently steering the model (injecting a
223
223
  // prompt when the user meant to STOP the run) is the dangerous direction of the ambiguity.
224
224
  if (trimmed.startsWith("/") && trimmed !== "/abort") {
225
- console.log(`[unknown command ${trimmed} /abort stops the run; a leading / is reserved]`);
225
+ void answerSlashInput(trimmed, control, (l) => console.log(l));
226
226
  return;
227
227
  }
228
228
  const command = trimmed === "/abort" ? { type: "abort" } : { type: "steer", prompt: { text: trimmed } };
@@ -423,6 +423,76 @@ const isAuthError = (error) => error instanceof ControlRequestError && error.sta
423
423
  /** `failStartup` borrowed for its print-one-line-and-exit behavior — attach can fail long after
424
424
  * startup (a serve restart hours in), so the local name must not imply "startup only". */
425
425
  const exitWith = failStartup;
426
+ /**
427
+ * The backfill slice REDUCED TO THE ACTIVE PATH — a session is a tree (a `navigate`, or a
428
+ * compaction, leaves sibling branches behind), and printing the slice raw renders the abandoned
429
+ * branch interleaved with the live one as if it were one conversation. Reachability is computed
430
+ * backwards from the leaf; an entry whose parent lies BEFORE the slice is path-connected by
431
+ * construction (its ancestors were rendered in an earlier round), so only what the walk cannot
432
+ * reach is dropped. An engine that reports NO leaf says nothing about branches, so its slice stands
433
+ * whole — the one case where "unknown" must not read as "off-path".
434
+ */
435
+ export function activePathSlice(entries, leafEntryId) {
436
+ if (leafEntryId === undefined)
437
+ return entries;
438
+ const byId = new Map(entries.map((e) => [e.id, e]));
439
+ // An append always moves the leaf, so a leaf BEHIND the slice means every entry in it was
440
+ // appended and then abandoned — a navigate backwards with no new turn since. Nothing here is on
441
+ // the active path.
442
+ if (!byId.has(leafEntryId))
443
+ return [];
444
+ const onPath = new Set();
445
+ for (let cur = byId.get(leafEntryId); cur; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
446
+ if (onPath.has(cur.id))
447
+ break;
448
+ onPath.add(cur.id);
449
+ }
450
+ return entries.filter((e) => onPath.has(e.id));
451
+ }
452
+ /**
453
+ * Answer a RESERVED-SLASH line (anything starting with `/` that is not `/abort`). Two intents share
454
+ * the prefix — a mistyped control command and an attempt to invoke a name — and they are answered
455
+ * differently:
456
+ *
457
+ * - a mistyped slash gets the certain half NOW (a leading `/` is reserved here, whatever the token
458
+ * turns out to be), because waiting on a remote read that can be slow or fail would leave the
459
+ * input unanswered; it deliberately does not pre-judge the token as unknown — the read may be
460
+ * about to prove it names a real skill;
461
+ * - `/commands` prints nothing first: its whole answer IS the read, and a placeholder is noise;
462
+ * - the enumeration answers `/commands` ONLY — dumping every skill at a mistyped `/aboort` answers
463
+ * an intent the typo did not express.
464
+ *
465
+ * Names print BARE: this composer cannot expand `/name` (the data plane takes prompts as text), so
466
+ * printing them with a slash would invite the user straight back into this branch. Returns the
467
+ * promise for the remote half, so a caller (or a test) can await the second line.
468
+ */
469
+ export async function answerSlashInput(trimmed, control, println) {
470
+ // The first WORD is the token: slash input naturally carries arguments (`/triage my inbox`), and
471
+ // taking the whole line would answer "names nothing" for a name the user did give.
472
+ const word = trimmed.slice(1).split(/\s+/)[0] ?? "";
473
+ const listing = word === "commands";
474
+ if (!listing)
475
+ println("[a leading / is reserved — /abort stops the run, /commands lists what this agent defines]");
476
+ let commands;
477
+ try {
478
+ commands = await control.commands();
479
+ }
480
+ catch (error) {
481
+ println(`[command list unavailable: ${error}]`);
482
+ return;
483
+ }
484
+ if (listing) {
485
+ // `description` is what makes a listing usable — a bare name tells the author nothing they did
486
+ // not already know from the directory.
487
+ const listed = commands.map((c) => (c.description ? `${c.name} — ${c.description}` : c.name));
488
+ println(listed.length ? `[this agent defines: ${listed.join("; ")}]` : "[this agent defines no names]");
489
+ return;
490
+ }
491
+ const hit = commands.find((c) => c.name === word);
492
+ println(hit
493
+ ? `[${hit.name} is a ${hit.source}${hit.description ? ` — ${hit.description}` : ""}; name it in a normal message, without the /]`
494
+ : `[/${word} names nothing this agent defines]`);
495
+ }
426
496
  /**
427
497
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
428
498
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -490,9 +560,11 @@ settleMs = 300) {
490
560
  // append-position cursor (design §7), and a leaf that sits before later appends (abandoned
491
561
  // branches) would make every reconnect permanently replay the same tail.
492
562
  next = backfill.entries.at(-1)?.id ?? cursor;
493
- if (backfill.entries.length > 0) {
563
+ // Cursor advancement is APPEND ORDER over the whole slice; RENDERING is the active path only.
564
+ const replay = activePathSlice(backfill.entries, backfill.leafEntryId);
565
+ if (replay.length > 0) {
494
566
  io.println("[replaying the record since the last sync (may overlap what you saw live)]");
495
- for (const entry of backfill.entries) {
567
+ for (const entry of replay) {
496
568
  let line;
497
569
  try {
498
570
  line = renderEntry(entry);
@@ -15,7 +15,7 @@ import { registerFeishuWebhook } from "../../channels/feishu/register-webhook.js
15
15
  import { readSlackBotAuthEnv } from "../../channels/slack/bot-auth.js";
16
16
  import { registerSlackWebhook } from "../../channels/slack/register-webhook.js";
17
17
  import { registerTelegramWebhook } from "../../channels/telegram/register-webhook.js";
18
- import { FORWARDER_FILE, TEMPLATE_FILE, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
18
+ import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
19
19
  import { deployAgentcoreRun } from "../../deploy/agentcore/run.js";
20
20
  import { isGeneratedDockerfile, isGeneratedDockerignore } from "../../deploy/container.js";
21
21
  import { composeHasTunnelService, dockerWebhookPaths, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
@@ -37,6 +37,10 @@ import { exists } from "../../paths.js";
37
37
  import { announceWebhooks } from "../../tunnel.js";
38
38
  import { failStartup, failUsage, placementOrExit } from "../fail.js";
39
39
  import { resolveFirstRunModel } from "../shared.js";
40
+ /** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
41
+ function shellArg(value) {
42
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
43
+ }
40
44
  export async function runDeploy(host, dirArg, opts) {
41
45
  // ONE deploy semantic: bake the WORKSPACE (WYSIWYG). Artifacts land under the agent dir
42
46
  // (`fastagent/`) plus the one workspace-root `.dockerignore` the packers require; host CLIs run
@@ -212,10 +216,7 @@ export async function runDeploy(host, dirArg, opts) {
212
216
  if (loaded.failures.length > 0) {
213
217
  failStartup(new Error(`deploy stopped: cannot load schedules: ${loaded.failures.map((x) => `${x.label}: ${x.message}`).join("; ")}`));
214
218
  }
215
- const acName = basename(workspace)
216
- .toLowerCase()
217
- .replace(/[^a-z0-9-]+/g, "-")
218
- .replace(/^-+|-+$/g, "") || "agent";
219
+ const acName = agentcoreName(basename(workspace));
219
220
  // Every derived AWS name embeds acName; the tightest ceiling is the Lambda function name
220
221
  // (`fastagent-<name>-forwarder` ≤ 64 chars). Gate the base instead of silently truncating —
221
222
  // truncation would break the redeploy identity (a renamed stack starts blank state).
@@ -605,6 +606,11 @@ async function runDeployAgentcore(params) {
605
606
  console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
606
607
  if (outcome.url)
607
608
  console.error(`[fastagent] webhook ingress → ${outcome.url}`);
609
+ const logsDir = shellArg(workspace);
610
+ console.error(`[fastagent] runtime logs → fastagent logs agentcore ${logsDir} --follow`);
611
+ if (params.needsForwarder) {
612
+ console.error(`[fastagent] forwarder logs → fastagent logs agentcore ${logsDir} --source forwarder --follow`);
613
+ }
608
614
  console.error(`[fastagent] invoke: aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ${outcome.runtimeArn} \\\n` +
609
615
  ` --runtime-session-id "my-conversation-000000000000000000" \\\n` +
610
616
  ` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
@@ -6,7 +6,7 @@
6
6
  import { resolve } from "node:path";
7
7
  import { runDevSupervisor } from "../../dev-supervisor.js";
8
8
  import { loadDotEnv } from "../../env.js";
9
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
9
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
10
10
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
11
11
  import { setLogLevel } from "../../log.js";
12
12
  import { logAgentLoop } from "../../observe.js";
@@ -91,5 +91,5 @@ function reportAgentsSkillsTools(a) {
91
91
  }
92
92
  reportToolCollisions(a.toolCollisions);
93
93
  reportModuleLoadFailures(a.toolFailures);
94
- reportDefinitionWarnings(a.definition.collisions, a.definition.diagnostics);
94
+ reportFindingsIfChanged(a.definition.dir, a.definition);
95
95
  }
@@ -6,7 +6,7 @@ import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModelSpec, reso
6
6
  import { resolveStateRoot, workspaceHint } from "../../paths.js";
7
7
  import { resolveAgentTools } from "../../engines/pi/create.js";
8
8
  import { loadAgentDefinition } from "../../engines/pi/definition.js";
9
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
9
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
10
10
  import { log } from "../../log.js";
11
11
  import { nextRun } from "../../schedule/cron.js";
12
12
  import { loadSchedules } from "../../schedule/discover.js";
@@ -111,5 +111,5 @@ export async function runInfo(dirArg, opts) {
111
111
  reportModuleLoadFailures(sched.failures);
112
112
  if (tools.error)
113
113
  log.warn(`[fastagent] ${tools.error}`);
114
- reportDefinitionWarnings(definition.collisions, definition.diagnostics);
114
+ reportFindingsIfChanged(definition.dir, definition);
115
115
  }
@@ -0,0 +1,6 @@
1
+ export interface AgentcoreLogsOptions {
2
+ source?: string;
3
+ since?: string;
4
+ follow?: boolean;
5
+ }
6
+ export declare function runLogs(host: string, dirArg: string, opts: AgentcoreLogsOptions): Promise<void>;
@@ -0,0 +1,27 @@
1
+ /** `fastagent logs agentcore [dir]`: discover and tail the deployed AgentCore CloudWatch logs. */
2
+ import { basename, resolve } from "node:path";
3
+ import { agentcoreName } from "../../deploy/agentcore/plan.js";
4
+ import { tailAgentcoreLogs } from "../../deploy/agentcore/logs.js";
5
+ import { spawnRunner } from "../../deploy/runner.js";
6
+ import { loadDotEnv } from "../../env.js";
7
+ import { failStartup, failUsage, placementOrExit } from "../fail.js";
8
+ export async function runLogs(host, dirArg, opts) {
9
+ // The host argument is DISPATCHED here, as in runDeploy — the parser's `choices` happens to have a
10
+ // single member today, and a future second host must land on its own reader, not silently on this one.
11
+ if (host !== "agentcore")
12
+ failUsage(`logs: unsupported host "${host}" — only agentcore has remote logs`);
13
+ const placement = placementOrExit(resolve(dirArg));
14
+ loadDotEnv(placement.agentDir); // AWS_PROFILE/region/proxy may be definition-local, as on deploy
15
+ const source = opts.source ?? "runtime";
16
+ if (source !== "runtime" && source !== "forwarder") {
17
+ failUsage(`logs: --source must be "runtime" or "forwarder"`);
18
+ }
19
+ const outcome = await tailAgentcoreLogs({
20
+ name: agentcoreName(basename(placement.workspace)),
21
+ source: source,
22
+ since: opts.since,
23
+ follow: opts.follow === true,
24
+ }, spawnRunner("aws", placement.workspace), (message) => console.error(`[fastagent] logs: ${message}`));
25
+ if (!outcome.ok)
26
+ failStartup(new Error(`logs stopped: ${outcome.gate}`));
27
+ }
@@ -9,7 +9,7 @@ import { loadDotEnv } from "../../env.js";
9
9
  import { resolveAuthPath, resolveSessionsDirOverride } from "../../engines/pi/config.js";
10
10
  import { resolveSecretsDir, workspaceHint } from "../../paths.js";
11
11
  import { isUnderDir } from "../../engines/pi/definition.js";
12
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
12
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
13
13
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
14
14
  import { log, setLogLevel } from "../../log.js";
15
15
  import { createWakeAlarmSink, reconcileWakeAlarms } from "../../schedule/wake-alarm.js";
@@ -80,7 +80,7 @@ export async function runStart(dirArg, opts) {
80
80
  log.info(`[fastagent] note: secrets (.env, rotated auth.json) live under the definition dir; point ` +
81
81
  `FASTAGENT_SECRETS_DIR at a persistent volume so a redeploy that replaces the dir does not wipe them.`);
82
82
  }
83
- reportDefinitionWarnings(definition.collisions, definition.diagnostics);
83
+ reportFindingsIfChanged(definition.dir, definition);
84
84
  // AgentCore Runtime posture (FASTAGENT_AGENTCORE=1, set by the generated deploy artifacts): the
85
85
  // adapter (POST /invocations + GET /ping) is the container's only reachable surface, and cron
86
86
  // slots arrive from the external clock through it — so no resident cron timers. In particular,
@@ -453,6 +453,31 @@ const schedule = {
453
453
  },
454
454
  ],
455
455
  };
456
+ const logs = {
457
+ name: "logs",
458
+ summary: "find and tail a deployed host's application logs",
459
+ description: "Find the CloudWatch log group for the AgentCore stack derived from dir, then run aws logs tail. " +
460
+ "The default Runtime source shows the agent process's own stdout/stderr; the forwarder source shows " +
461
+ "the Lambda ingress transport logs.",
462
+ args: [{ name: "<host>", description: "deployed host", choices: ["agentcore"] }, DIR_ARG],
463
+ flags: [
464
+ { flags: "--source <source>", description: "agentcore log source: runtime (default) or forwarder" },
465
+ { flags: "--since <duration>", description: "history window accepted by AWS CLI (for example 30m or 2h)" },
466
+ { flags: "--follow", description: "keep polling for new log events until interrupted" },
467
+ ],
468
+ examples: [
469
+ { cmd: "fastagent logs agentcore --follow", note: "the agent process" },
470
+ { cmd: "fastagent logs agentcore --source forwarder --follow", note: "Lambda ingress" },
471
+ ],
472
+ notes: "Read-only. Run it against the same workspace passed to deploy so it derives the same CloudFormation " +
473
+ "stack name. It never changes FASTAGENT_LOG_LEVEL: AgentCore keeps start's production default, and " +
474
+ "setting that environment knob to debug exposes the existing detailed turn trace when needed.",
475
+ run: async (args, f) => (await import("./commands/logs.js")).runLogs(args[0], args[1], {
476
+ source: f.source,
477
+ since: f.since,
478
+ follow: f.follow === true,
479
+ }),
480
+ };
456
481
  const login = {
457
482
  name: "login",
458
483
  summary: "authenticate a model provider (subscription/OAuth or API key)",
@@ -486,6 +511,7 @@ export const specs = [
486
511
  start,
487
512
  add,
488
513
  deploy,
514
+ logs,
489
515
  login,
490
516
  ];
491
517
  /**
@@ -0,0 +1,35 @@
1
+ /**
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.
12
+ */
13
+ import type { CliRunner } from "../runner.ts";
14
+ export type AgentcoreLogSource = "runtime" | "forwarder";
15
+ export interface AgentcoreLogsPlan {
16
+ /** Deployment base name — stack `fastagent-<name>`, forwarder `fastagent-<name>-forwarder`. */
17
+ name: string;
18
+ source: AgentcoreLogSource;
19
+ /** AWS CLI relative/ISO-8601 window (`10m`, `2h`, ...). Defaults to the CLI's own 10 minutes. */
20
+ since?: string;
21
+ follow: boolean;
22
+ }
23
+ export type AgentcoreLogsOutcome = {
24
+ ok: true;
25
+ logGroup: string;
26
+ } | {
27
+ ok: false;
28
+ gate: string;
29
+ };
30
+ /**
31
+ * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
32
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
33
+ * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
34
+ */
35
+ export declare function tailAgentcoreLogs(plan: AgentcoreLogsPlan, aws: CliRunner, announce?: (message: string) => void): Promise<AgentcoreLogsOutcome>;
@@ -0,0 +1,112 @@
1
+ import { parseStackOutputs } from "./run.js";
2
+ /** Runtime id from `arn:...:runtime/<id>` — the id prefixes AgentCore's per-endpoint log group. */
3
+ function runtimeIdFromArn(arn) {
4
+ const marker = ":runtime/";
5
+ const at = arn.lastIndexOf(marker);
6
+ const id = at === -1 ? "" : arn.slice(at + marker.length);
7
+ return id && !id.includes("/") ? id : undefined;
8
+ }
9
+ function parseLogGroupNames(stdout) {
10
+ try {
11
+ const parsed = JSON.parse(stdout);
12
+ return Array.isArray(parsed) && parsed.every((v) => typeof v === "string") ? parsed : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ /**
19
+ * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
20
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
21
+ * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
22
+ */
23
+ export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
24
+ const stack = `fastagent-${plan.name}`;
25
+ const outputsResult = await aws(["cloudformation", "describe-stacks", "--stack-name", stack, "--query", "Stacks[0].Outputs", "--output", "json"], { capture: true });
26
+ if (outputsResult.code === 127) {
27
+ return { ok: false, gate: "aws CLI not found — install AWS CLI v2: https://docs.aws.amazon.com/cli/" };
28
+ }
29
+ if (outputsResult.code !== 0) {
30
+ return {
31
+ ok: false,
32
+ gate: `could not read AgentCore stack ${stack} — deploy it first, or fix the AWS account/region shown above`,
33
+ };
34
+ }
35
+ const outputs = parseStackOutputs(outputsResult.stdout);
36
+ let prefix;
37
+ let exact;
38
+ if (plan.source === "runtime") {
39
+ const runtimeArn = outputs.RuntimeArn;
40
+ const runtimeId = runtimeArn && runtimeIdFromArn(runtimeArn);
41
+ if (!runtimeId) {
42
+ return {
43
+ ok: false,
44
+ gate: `stack ${stack} has no valid RuntimeArn output — regenerate/deploy the AgentCore stack`,
45
+ };
46
+ }
47
+ prefix = `/aws/bedrock-agentcore/runtimes/${runtimeId}-`;
48
+ }
49
+ else {
50
+ // `ForwarderUrl` is the stack's INGRESS URL, NOT proof that a forwarder Lambda exists: plan.ts
51
+ // keeps needsForwarder and needsFunctionUrl as two variables on purpose (a schedules-only topology
52
+ // may keep the forwarder and drop the public URL). So DISCOVERY decides existence below, and the
53
+ // URL output only picks which not-found sentence is true.
54
+ exact = `/aws/lambda/fastagent-${plan.name}-forwarder`;
55
+ prefix = exact;
56
+ }
57
+ const groupsResult = await aws([
58
+ "logs",
59
+ "describe-log-groups",
60
+ "--log-group-name-prefix",
61
+ prefix,
62
+ "--query",
63
+ "logGroups[].logGroupName",
64
+ "--output",
65
+ "json",
66
+ ], { capture: true });
67
+ if (groupsResult.code !== 0) {
68
+ return { ok: false, gate: "could not discover the CloudWatch log group — see the AWS error above" };
69
+ }
70
+ const groups = parseLogGroupNames(groupsResult.stdout);
71
+ if (!groups) {
72
+ return { ok: false, gate: "AWS returned an invalid CloudWatch log-group response" };
73
+ }
74
+ const matches = groups.filter((group) => (exact ? group === exact : group.startsWith(prefix))).sort();
75
+ if (matches.length === 0) {
76
+ // Absent group = never used, EXCEPT when the stack has no forwarder at all — an invoke-only
77
+ // deployment would otherwise be told to deliver a webhook it can never receive. Both facts agree
78
+ // there (no ingress URL output either), so the message can name the topology instead of a trigger.
79
+ if (plan.source === "forwarder" && !outputs.ForwarderUrl) {
80
+ return {
81
+ ok: false,
82
+ gate: `stack ${stack} has neither a forwarder log group nor an ingress URL — this looks like an invoke-only deployment, which has Runtime logs only`,
83
+ };
84
+ }
85
+ const trigger = plan.source === "runtime" ? "invoke the Runtime once" : "deliver one webhook or schedule fire";
86
+ return {
87
+ ok: false,
88
+ gate: `no ${plan.source} log group exists yet — ${trigger}, then retry (AWS creates it on first use)`,
89
+ };
90
+ }
91
+ // A generated stack has one Runtime endpoint. If an operator's edited stack has several, choosing
92
+ // one silently would show a valid but potentially WRONG agent log — list them and make the choice explicit.
93
+ if (matches.length > 1) {
94
+ return {
95
+ ok: false,
96
+ gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one directly: ` +
97
+ `aws logs tail <group> --format short --follow`,
98
+ };
99
+ }
100
+ const logGroup = matches[0];
101
+ announce(`${plan.source} → ${logGroup}`);
102
+ const tailArgs = ["logs", "tail", logGroup, "--format", "short"];
103
+ if (plan.since)
104
+ tailArgs.push("--since", plan.since);
105
+ if (plan.follow)
106
+ tailArgs.push("--follow");
107
+ const tailed = await aws(tailArgs);
108
+ if (tailed.code !== 0) {
109
+ return { ok: false, gate: `aws logs tail failed for ${logGroup} — see the AWS error above` };
110
+ }
111
+ return { ok: true, logGroup };
112
+ }
@@ -37,13 +37,35 @@ export interface AgentcorePlan {
37
37
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
38
38
  * Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
39
39
  export declare const MOUNT = "/mnt/state";
40
+ /**
41
+ * FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
42
+ * rather than beside it.
43
+ *
44
+ * Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
45
+ * + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
46
+ * survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
47
+ * directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
48
+ * itself is wiped on every runtime version update, i.e. on every deploy.
49
+ *
50
+ * So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
51
+ * copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
52
+ * single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
53
+ * is rotated away, then loses model access with only a redeploy to restore it.
54
+ *
55
+ * Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
56
+ * auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
57
+ * this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
58
+ * reintroduces the outage silently.
59
+ */
60
+ export declare const SECRETS_DIR = "/mnt/state/.secrets";
40
61
  /**
41
62
  * How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
42
63
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
43
64
  * activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
44
65
  * platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
45
- * for anyone who returns after a longer gap. `/ping` reports HealthyBusy while work is in flight, so
46
- * this timer only ever starts once the agent has genuinely settled a long turn is never cut short.
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.
47
69
  * AWS accepts 60–28800.
48
70
  */
49
71
  export declare const IDLE_TIMEOUT_SECONDS = 180;
@@ -70,6 +92,8 @@ export declare const TEMPLATE_FILE = "agentcore.template.yaml";
70
92
  export declare const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agentcore`";
71
93
  /** Whether an on-disk template is fastagent-generated (vs hand-written — kept, never gated). */
72
94
  export declare function isGeneratedAgentcoreTemplate(content: string): boolean;
95
+ /** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
96
+ export declare function agentcoreName(workspaceBasename: string): string;
73
97
  /** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
74
98
  export declare function toRuntimeName(basename: string): string;
75
99
  /** The ONE fixed ingress session id (webhooks + schedule fires) — ≥ 33 chars (the API minimum),