@fastagent-sh/fastagent 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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). */
@@ -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,6 +77,8 @@ 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
  }
@@ -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,16 @@ 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[];
65
75
  /**
66
76
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
67
77
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -423,6 +423,32 @@ 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
+ }
426
452
  /**
427
453
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
428
454
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -490,9 +516,11 @@ settleMs = 300) {
490
516
  // append-position cursor (design §7), and a leaf that sits before later appends (abandoned
491
517
  // branches) would make every reconnect permanently replay the same tail.
492
518
  next = backfill.entries.at(-1)?.id ?? cursor;
493
- if (backfill.entries.length > 0) {
519
+ // Cursor advancement is APPEND ORDER over the whole slice; RENDERING is the active path only.
520
+ const replay = activePathSlice(backfill.entries, backfill.leafEntryId);
521
+ if (replay.length > 0) {
494
522
  io.println("[replaying the record since the last sync (may overlap what you saw live)]");
495
- for (const entry of backfill.entries) {
523
+ for (const entry of replay) {
496
524
  let line;
497
525
  try {
498
526
  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`);
@@ -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
+ }
@@ -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 selects only [runtime-logs], so application stdout/stderr is not mixed " +
461
+ "with OTEL/spans in the same AWS log group; the forwarder source shows 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,30 @@
1
+ /**
2
+ * AgentCore log discovery + tailing. AWS puts the container's stdout/stderr and its OTEL telemetry
3
+ * in the same per-endpoint CloudWatch log group, while the forwarder Lambda has a separate group.
4
+ * This operator surface resolves the stack's RuntimeArn, discovers the endpoint group by prefix, and
5
+ * tails ONLY `[runtime-logs]` for the runtime source — the same application lines shown locally,
6
+ * without mixing in spans/otel-rt-logs.
7
+ */
8
+ import type { CliRunner } from "../runner.ts";
9
+ export type AgentcoreLogSource = "runtime" | "forwarder";
10
+ export interface AgentcoreLogsPlan {
11
+ /** Deployment base name — stack `fastagent-<name>`, forwarder `fastagent-<name>-forwarder`. */
12
+ name: string;
13
+ source: AgentcoreLogSource;
14
+ /** AWS CLI relative/ISO-8601 window (`10m`, `2h`, ...). Defaults to the CLI's own 10 minutes. */
15
+ since?: string;
16
+ follow: boolean;
17
+ }
18
+ export type AgentcoreLogsOutcome = {
19
+ ok: true;
20
+ logGroup: string;
21
+ } | {
22
+ ok: false;
23
+ gate: string;
24
+ };
25
+ /**
26
+ * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
27
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
28
+ * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
29
+ */
30
+ export declare function tailAgentcoreLogs(plan: AgentcoreLogsPlan, aws: CliRunner, announce?: (message: string) => void): Promise<AgentcoreLogsOutcome>;
@@ -0,0 +1,115 @@
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 with the ` +
97
+ `same stream filter this command applies: aws logs tail <group> --log-stream-name-prefix '[runtime-logs]' ` +
98
+ `(quote the prefix — [...] is a shell glob)`,
99
+ };
100
+ }
101
+ const logGroup = matches[0];
102
+ announce(`${plan.source} → ${logGroup}`);
103
+ const tailArgs = ["logs", "tail", logGroup, "--format", "short"];
104
+ if (plan.since)
105
+ tailArgs.push("--since", plan.since);
106
+ if (plan.source === "runtime")
107
+ tailArgs.push("--log-stream-name-prefix", "[runtime-logs]");
108
+ if (plan.follow)
109
+ tailArgs.push("--follow");
110
+ const tailed = await aws(tailArgs);
111
+ if (tailed.code !== 0) {
112
+ return { ok: false, gate: `aws logs tail failed for ${logGroup} — see the AWS error above` };
113
+ }
114
+ return { ok: true, logGroup };
115
+ }
@@ -37,6 +37,27 @@ 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
@@ -70,6 +91,8 @@ export declare const TEMPLATE_FILE = "agentcore.template.yaml";
70
91
  export declare const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agentcore`";
71
92
  /** Whether an on-disk template is fastagent-generated (vs hand-written — kept, never gated). */
72
93
  export declare function isGeneratedAgentcoreTemplate(content: string): boolean;
94
+ /** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
95
+ export declare function agentcoreName(workspaceBasename: string): string;
73
96
  /** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
74
97
  export declare function toRuntimeName(basename: string): string;
75
98
  /** The ONE fixed ingress session id (webhooks + schedule fires) — ≥ 33 chars (the API minimum),
@@ -29,12 +29,34 @@
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
31
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
32
+ import { SECRETS_DIRNAME } from "../../paths.js";
32
33
  import { containerArtifacts } from "../container.js";
33
34
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
34
35
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
35
36
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
36
37
  * Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
37
38
  export const MOUNT = "/mnt/state";
39
+ /**
40
+ * FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
41
+ * rather than beside it.
42
+ *
43
+ * Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
44
+ * + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
45
+ * survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
46
+ * directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
47
+ * itself is wiped on every runtime version update, i.e. on every deploy.
48
+ *
49
+ * So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
50
+ * copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
51
+ * single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
52
+ * is rotated away, then loses model access with only a redeploy to restore it.
53
+ *
54
+ * Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
55
+ * auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
56
+ * this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
57
+ * reintroduces the outage silently.
58
+ */
59
+ export const SECRETS_DIR = `${MOUNT}/${SECRETS_DIRNAME}`;
38
60
  /**
39
61
  * How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
40
62
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
@@ -72,6 +94,13 @@ export const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agent
72
94
  export function isGeneratedAgentcoreTemplate(content) {
73
95
  return content.startsWith(GENERATED_TEMPLATE_MARKER);
74
96
  }
97
+ /** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
98
+ export function agentcoreName(workspaceBasename) {
99
+ return (workspaceBasename
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9-]+/g, "-")
102
+ .replace(/^-+|-+$/g, "") || "agent");
103
+ }
75
104
  /** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
76
105
  export function toRuntimeName(basename) {
77
106
  const slug = basename.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -437,6 +466,10 @@ function template(input, translated) {
437
466
  ` PORT: "8080"`, // the Runtime service contract's fixed port (config.http.port does not apply here)
438
467
  ` FASTAGENT_AGENTCORE: "1"`, // serve mounts /invocations + /ping, arms no resident cron
439
468
  ` FASTAGENT_STATE_DIR: ${MOUNT}`,
469
+ // Inside the state root on purpose — the snapshot is this host's only durable store, and it copies
470
+ // exactly one tree. See {@link SECRETS_DIR}: the sibling layout every other host uses would leave a
471
+ // rotated OAuth credential outside it, i.e. discarded with the microVM.
472
+ ` FASTAGENT_SECRETS_DIR: ${SECRETS_DIR}`,
440
473
  ];
441
474
  // The auth seed is chunked (env values max 2048 chars — see AUTH_SEED_CHUNK_SIZE): N parameters,
442
475
  // each riding its own env var; `start` reassembles them (collectAuthSeed). Empty defaults = unused.
@@ -679,7 +712,12 @@ export function planAgentcoreDeploy(input) {
679
712
  ? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
680
713
  : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
681
714
  ? []
682
- : [`# 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"`);
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 filters to [runtime-logs], excluding OTEL/spans AWS stores in the same log group:`, `fastagent logs agentcore --follow`, ...(needsForwarder
716
+ ? [
717
+ `# The ingress transport is a separate Lambda and therefore a separate log source:`,
718
+ `fastagent logs agentcore --source forwarder --follow`,
719
+ ]
720
+ : []));
683
721
  // Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored
684
722
  // can't be read at plan time — `--run` carries it as FastagentAuthSeed.
685
723
  if (!isEnvKey(input.modelAuth)) {
@@ -716,6 +754,6 @@ export function planAgentcoreDeploy(input) {
716
754
  if (needsForwarder) {
717
755
  runbook.push(``, `# After a REDEPLOY, stop the ingress session so the new image serves immediately — a live session`, `# keeps its old compute (and the OLD image) until ${IDLE_TIMEOUT_SECONDS}s idle / the 8 h compute ceiling`, `# (\`--run\` does this automatically):`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`);
718
756
  }
719
- runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`);
757
+ runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`, `# CREDENTIALS RIDE THAT SNAPSHOT TOO: FASTAGENT_SECRETS_DIR is ${SECRETS_DIR}, inside the state`, `# root, so an OAuth auth.json ROTATED on the box persists (a refresh token is single-use — without`, `# this the next microVM would re-seed the deploy-time copy and eventually fail to authenticate).`, `# The bucket is therefore credential storage: it is created with public access blocked and`, `# versioning on, and deleting it costs model access until the next deploy re-seeds.`);
720
758
  return { artifacts, runbook, untranslatableSchedules: untranslatable };
721
759
  }
@@ -128,8 +128,10 @@ CMD ["./${into("node_modules/.bin/fastagent")}", "start", "/app"]
128
128
  /** Patterns are RECURSIVE (`**​/`) on purpose — dockerignore patterns are root-anchored (unlike
129
129
  * .gitignore), and a baked workspace can hold nested projects: a bare `node_modules` would upload
130
130
  * their build-machine deps (macOS binaries!) and a bare `.env` would bake their secrets into the
131
- * image. `.secrets`/`.state` are fastagent machinery — secrets travel through the host's secret
132
- * store, state lives on the volume; neither may ever enter an image. `.cache` is generic hygiene
131
+ * image. `.secrets`/`.state` are fastagent machinery — credential contents travel through the host's
132
+ * secret store and state lives on the volume, so neither may enter an image. The two tracked secrets
133
+ * scaffolds (`.env.example` + `.gitignore`) are the narrow exception: they carry no values and must stay
134
+ * beside the shipped `.git`, or the image starts with tracked deletions. `.cache` is generic hygiene
133
135
  * (a baked project's own build cache), not a fastagent directory.
134
136
  * `.git` is deliberately SHIPPED: the deployed agent's write-back (pull/commit/push) needs the
135
137
  * repo's history+remote — the WYSIWYG bake's freshness/durability loop runs through git, driven by
@@ -145,12 +147,13 @@ const dockerignore = (input) => DOCKERIGNORE_BASE +
145
147
  .join("");
146
148
  const DOCKERIGNORE_BASE = `${GENERATED_DOCKERIGNORE_MARKER}. Delete this line to take ownership (deploy then keeps your file).
147
149
  **/node_modules
148
- **/${SECRETS_DIRNAME}
150
+ **/${SECRETS_DIRNAME}/**
149
151
  **/${STATE_DIRNAME}
150
152
  **/.cache
151
153
  **/.env
152
154
  **/.env.*
153
155
  !**/.env.example
156
+ !**/${SECRETS_DIRNAME}/.gitignore
154
157
  **/*.log
155
158
  # .git is deliberately shipped: the agent can pull to freshen content and push its work back
156
159
  # (the generated image installs the git binary when this directory ships a .git; otherwise
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { FastagentTool } from "./tool.ts";
3
3
  import type { Models } from "@earendil-works/pi-ai";
4
- import { type AnyModel } from "./harness.ts";
4
+ import type { AnyModel } from "./harness.ts";
5
5
  export interface FastagentConfig {
6
6
  /** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
7
7
  model?: string;
@@ -20,7 +20,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
20
20
  import { existsSync, statSync } from "node:fs";
21
21
  import { basename, join } from "node:path";
22
22
  import { pathToFileURL } from "node:url";
23
- import { THINKING_LEVELS } from "./harness.js";
23
+ import { THINKING_LEVELS } from "./session-settings.js";
24
24
  import { isBindAddress } from "../../bind.js";
25
25
  import { moduleLoadHint } from "../../loader.js";
26
26
  import { AGENT_CONFIG_NAMES, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
@@ -3,7 +3,7 @@ import type { Models, Provider } from "@earendil-works/pi-ai";
3
3
  import type { Agent } from "../../agent.ts";
4
4
  import { type FastagentConfig } from "./config.ts";
5
5
  import { type LoadedDefinition } from "./definition.ts";
6
- import { piHarnessFactory } from "./harness.ts";
6
+ import { type AnyModel, piHarnessFactory } from "./harness.ts";
7
7
  import { type PiSessionStore } from "./sessions.ts";
8
8
  import type { ModuleLoadFailure } from "../../loader.ts";
9
9
  import { type ToolCollision, type MountedTool } from "./tool.ts";
@@ -64,6 +64,11 @@ type OnAssembly = (parts: {
64
64
  models: Models;
65
65
  harnessFactory: ReturnType<typeof piHarnessFactory>;
66
66
  lease: Lease;
67
+ /** The resolved configured pair — what a session without overrides runs on. */
68
+ defaults: {
69
+ model: AnyModel;
70
+ thinkingLevel: ThinkingLevel;
71
+ };
67
72
  }) => void;
68
73
  /** L1 options. Tier 1: model (spec) + instructions + tools. Tier 2: the injectable ports. */
69
74
  export interface CreatePiAgentOptions {
@@ -17,7 +17,7 @@ import { readImageProcessor } from "./read-image.js";
17
17
  import { defaultAuthPath, resolveModel } from "./config.js";
18
18
  import { resolveSecretsDir } from "../../paths.js";
19
19
  import { loadAgentDefinition } from "./definition.js";
20
- import { piHarnessFactory } from "./harness.js";
20
+ import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
21
21
  import { createPiModels } from "./models.js";
22
22
  import { reportDefinitionWarnings } from "./report.js";
23
23
  import { inMemorySessionStore } from "./sessions.js";
@@ -169,18 +169,24 @@ function buildPiAgent(opts) {
169
169
  // Materialized here (not defaulted inside createPiAgentFromHarness) so the exposed parts carry
170
170
  // the SAME lease instance the agent runs under — boundary mutations must contend on it.
171
171
  const lease = opts.lease ?? inProcessLease();
172
+ // The assembly's configured PAIR — handed to the factory and to the control plane as ONE value, so
173
+ // there is no wiring in which they could disagree (which levels exist depends on the model).
174
+ const defaults = {
175
+ model: resolveModel(models, opts.model),
176
+ thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
177
+ };
172
178
  const harnessFactory = piHarnessFactory({
173
179
  sessions: opts.sessions ?? inMemorySessionStore(),
174
180
  env,
175
181
  models,
176
- model: resolveModel(models, opts.model),
182
+ model: defaults.model,
177
183
  thinkingLevel: opts.thinkingLevel,
178
184
  systemPrompt: opts.systemPrompt,
179
185
  tools: opts.tools,
180
186
  skills: opts.skills,
181
187
  live: opts.live,
182
188
  });
183
- opts.onAssembly?.({ models, harnessFactory, lease });
189
+ opts.onAssembly?.({ models, harnessFactory, lease, defaults });
184
190
  return createPiAgentFromHarness({ lease, observer: opts.observer, cwd: env.cwd, harnessFactory });
185
191
  }
186
192
  /**