@fastagent-sh/fastagent 0.16.0 → 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.
- package/README.md +1 -1
- package/dist/bind.d.ts +34 -0
- package/dist/bind.js +74 -0
- package/dist/channels/agentcore-state.js +21 -6
- package/dist/channels/control.js +3 -0
- package/dist/cli/commands/attach.d.ts +11 -1
- package/dist/cli/commands/attach.js +30 -2
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +18 -6
- package/dist/cli/commands/dev.d.ts +1 -0
- package/dist/cli/commands/dev.js +15 -4
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/logs.d.ts +6 -0
- package/dist/cli/commands/logs.js +27 -0
- package/dist/cli/commands/start.d.ts +1 -0
- package/dist/cli/commands/start.js +11 -3
- package/dist/cli/commands/tool.js +10 -2
- package/dist/cli/program.js +35 -0
- package/dist/cli/serve.d.ts +26 -2
- package/dist/cli/serve.js +71 -17
- package/dist/cli/shared.d.ts +6 -0
- package/dist/cli/shared.js +14 -0
- package/dist/deploy/agentcore/logs.d.ts +30 -0
- package/dist/deploy/agentcore/logs.js +115 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -0
- package/dist/deploy/agentcore/plan.js +41 -3
- package/dist/deploy/container.js +6 -3
- package/dist/deploy/preflight.js +18 -0
- package/dist/engines/pi/config.d.ts +7 -3
- package/dist/engines/pi/config.js +9 -3
- package/dist/engines/pi/create.d.ts +31 -18
- package/dist/engines/pi/create.js +46 -17
- package/dist/engines/pi/harness.d.ts +31 -33
- package/dist/engines/pi/harness.js +24 -76
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +2 -1
- package/dist/engines/pi/read-image.d.ts +4 -0
- package/dist/engines/pi/read-image.js +62 -0
- package/dist/engines/pi/search-tools.d.ts +6 -4
- package/dist/engines/pi/search-tools.js +3 -1
- package/dist/engines/pi/session-builder.js +7 -2
- package/dist/engines/pi/session-control.d.ts +12 -3
- package/dist/engines/pi/session-control.js +156 -27
- package/dist/engines/pi/session-settings.d.ts +51 -0
- package/dist/engines/pi/session-settings.js +73 -0
- package/dist/engines/pi/sessions.d.ts +21 -7
- package/dist/engines/pi/sessions.js +43 -0
- package/dist/engines/pi/tool.d.ts +13 -5
- package/dist/engines/pi/wake-tool.d.ts +3 -3
- package/dist/host/node.d.ts +2 -0
- package/dist/host/node.js +2 -1
- package/dist/pi.d.ts +1 -1
- package/dist/session.d.ts +34 -9
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
|
|
|
196
196
|
The neutral contract leaves room for capabilities that are not complete product features yet:
|
|
197
197
|
|
|
198
198
|
- **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
|
|
199
|
-
- **Sandboxed execution** — `ExecutionEnv`
|
|
199
|
+
- **Sandboxed execution** — `ExecutionEnv` governs the default coding tools, but ② project context and author-written `tools/` still reach the local process; a complete sandbox adapter is future work.
|
|
200
200
|
- **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
|
|
201
201
|
- **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
|
|
202
202
|
- **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
|
package/dist/bind.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A bindable host: an IP literal (v4/v6, brackets optional) or "localhost". No other DNS name — a
|
|
2
|
+
* bind address must be an address of THIS machine, and resolving one is not this module's job. */
|
|
3
|
+
export declare function isBindAddress(host: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* The ADDRESS form of an accepted bind value: `localhost` becomes `127.0.0.1`, everything else is
|
|
6
|
+
* already an address. Applied where a value ENTERS (the flag, `http.host`), so nothing downstream ever
|
|
7
|
+
* holds a name — not deferring the resolution but removing it, which is the module's whole point.
|
|
8
|
+
*
|
|
9
|
+
* Two things go wrong otherwise, and both are silent. `server.listen` hands the name to `dns.lookup`,
|
|
10
|
+
* which picks ONE of 127.0.0.1/::1 by rules we do not control — so what got bound is unknown here. And
|
|
11
|
+
* `clientHost` would then write that NAME into control.json and the copyable curl, where a consumer
|
|
12
|
+
* resolves it again, possibly to the other one.
|
|
13
|
+
*/
|
|
14
|
+
export declare function bindAddress(host: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* How far a bind address reaches: `wildcard` (unset or all-interfaces) reaches every interface and
|
|
17
|
+
* answers on loopback too; `loopback` is this machine only; `specific` is one interface, reachable
|
|
18
|
+
* only as itself. Loopback covers the whole reserved range (127/8, ::1) — a `127.0.0.2` bind is no
|
|
19
|
+
* more LAN-reachable than `127.0.0.1`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function classifyBind(host: string | undefined): "wildcard" | "loopback" | "specific";
|
|
22
|
+
/**
|
|
23
|
+
* Does a serve bound to `host` answer a dial of the NAME `localhost`? Only the addresses that name
|
|
24
|
+
* resolves to (127.0.0.1 / ::1) and a wildcard bind do — `127.0.0.2` is loopback yet unreachable that
|
|
25
|
+
* way. cloudflared dials by name, so `--tunnel` needs this question, not `classifyBind`'s reach.
|
|
26
|
+
*/
|
|
27
|
+
export declare function answersLocalhost(host: string | undefined): boolean;
|
|
28
|
+
/** How to NAME a bind in a message: the wildcard is every interface, so calling it one address would
|
|
29
|
+
* understate it; anything else is dialable as itself. THE renderer — the ready lines, the
|
|
30
|
+
* already-in-use refusal and the generic bind failure all read a bind through this one, so a reader
|
|
31
|
+
* never sees the same bind described two ways. */
|
|
32
|
+
export declare function bindLabel(host: string | undefined, port: number): string;
|
|
33
|
+
/** The address a local client should dial for a serve bound to `host` (control.json, the ready log). */
|
|
34
|
+
export declare function clientHost(host: string | undefined): string;
|
package/dist/bind.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE reading of a bind address, shared by everything that parses, binds, warns about, or ships
|
|
3
|
+
* one (the CLI flag, `http.host` validation, the Node host, the deploy pre-flight). Engine- and
|
|
4
|
+
* host-neutral on purpose: a bind address is a plain value, and two parsers of it would disagree.
|
|
5
|
+
*/
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
|
+
/** Lowercase, unbracketed, IPv4-mapped IPv6 reduced to its IPv4 form — the form the checks below read.
|
|
8
|
+
* Brackets come off only as a PAIR: a half-bracketed `[::1` must stay invalid, not become an address. */
|
|
9
|
+
function normalize(host) {
|
|
10
|
+
return host
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/^\[(.+)]$/, "$1")
|
|
13
|
+
.replace(/^::ffff:/, "");
|
|
14
|
+
}
|
|
15
|
+
/** A bindable host: an IP literal (v4/v6, brackets optional) or "localhost". No other DNS name — a
|
|
16
|
+
* bind address must be an address of THIS machine, and resolving one is not this module's job. */
|
|
17
|
+
export function isBindAddress(host) {
|
|
18
|
+
const h = normalize(host);
|
|
19
|
+
return h === "localhost" || isIP(h) !== 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The ADDRESS form of an accepted bind value: `localhost` becomes `127.0.0.1`, everything else is
|
|
23
|
+
* already an address. Applied where a value ENTERS (the flag, `http.host`), so nothing downstream ever
|
|
24
|
+
* holds a name — not deferring the resolution but removing it, which is the module's whole point.
|
|
25
|
+
*
|
|
26
|
+
* Two things go wrong otherwise, and both are silent. `server.listen` hands the name to `dns.lookup`,
|
|
27
|
+
* which picks ONE of 127.0.0.1/::1 by rules we do not control — so what got bound is unknown here. And
|
|
28
|
+
* `clientHost` would then write that NAME into control.json and the copyable curl, where a consumer
|
|
29
|
+
* resolves it again, possibly to the other one.
|
|
30
|
+
*/
|
|
31
|
+
export function bindAddress(host) {
|
|
32
|
+
return normalize(host) === "localhost" ? "127.0.0.1" : host;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* How far a bind address reaches: `wildcard` (unset or all-interfaces) reaches every interface and
|
|
36
|
+
* answers on loopback too; `loopback` is this machine only; `specific` is one interface, reachable
|
|
37
|
+
* only as itself. Loopback covers the whole reserved range (127/8, ::1) — a `127.0.0.2` bind is no
|
|
38
|
+
* more LAN-reachable than `127.0.0.1`.
|
|
39
|
+
*/
|
|
40
|
+
export function classifyBind(host) {
|
|
41
|
+
if (host === undefined)
|
|
42
|
+
return "wildcard";
|
|
43
|
+
const h = normalize(host);
|
|
44
|
+
if (h === "0.0.0.0" || h === "::" || h === "::0")
|
|
45
|
+
return "wildcard";
|
|
46
|
+
if (h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h))
|
|
47
|
+
return "loopback";
|
|
48
|
+
return "specific";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Does a serve bound to `host` answer a dial of the NAME `localhost`? Only the addresses that name
|
|
52
|
+
* resolves to (127.0.0.1 / ::1) and a wildcard bind do — `127.0.0.2` is loopback yet unreachable that
|
|
53
|
+
* way. cloudflared dials by name, so `--tunnel` needs this question, not `classifyBind`'s reach.
|
|
54
|
+
*/
|
|
55
|
+
export function answersLocalhost(host) {
|
|
56
|
+
if (classifyBind(host) === "wildcard")
|
|
57
|
+
return true;
|
|
58
|
+
const h = normalize(host);
|
|
59
|
+
return h === "localhost" || h === "127.0.0.1" || h === "::1";
|
|
60
|
+
}
|
|
61
|
+
/** How to NAME a bind in a message: the wildcard is every interface, so calling it one address would
|
|
62
|
+
* understate it; anything else is dialable as itself. THE renderer — the ready lines, the
|
|
63
|
+
* already-in-use refusal and the generic bind failure all read a bind through this one, so a reader
|
|
64
|
+
* never sees the same bind described two ways. */
|
|
65
|
+
export function bindLabel(host, port) {
|
|
66
|
+
return classifyBind(host) === "wildcard" ? `port ${port}` : `${clientHost(host)}:${port}`;
|
|
67
|
+
}
|
|
68
|
+
/** The address a local client should dial for a serve bound to `host` (control.json, the ready log). */
|
|
69
|
+
export function clientHost(host) {
|
|
70
|
+
if (classifyBind(host) === "wildcard")
|
|
71
|
+
return "127.0.0.1";
|
|
72
|
+
// biome-ignore lint/style/noNonNullAssertion: only a wildcard bind leaves host undefined
|
|
73
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
74
|
+
}
|
|
@@ -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). */
|
|
@@ -126,17 +133,25 @@ export function createStateSync(options) {
|
|
|
126
133
|
let looping = false;
|
|
127
134
|
const runRestore = async (urls) => {
|
|
128
135
|
const res = await doFetch(urls.getUrl, { method: "GET" });
|
|
129
|
-
// ONLY a proven 404 is "first deploy".
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
136
|
+
// ONLY a proven 404 is "first deploy". A missing key answers 404 only because the generated
|
|
137
|
+
// template grants the signer s3:ListBucket on the snapshot prefix — without it S3 folds "absent"
|
|
138
|
+
// into 403 (anti-enumeration). A 403 therefore means an expired or malformed signature, a
|
|
139
|
+
// revoked permission, or a template from before that grant — i.e. the snapshot may well exist.
|
|
140
|
+
// Reading 403 as "absent" would serve an empty agent and then overwrite the real snapshot with
|
|
141
|
+
// that emptiness.
|
|
133
142
|
if (res.status === 404) {
|
|
134
143
|
log.info("[agentcore] no state snapshot yet — starting from an empty state root (first deploy)");
|
|
135
144
|
restored = true;
|
|
136
145
|
return;
|
|
137
146
|
}
|
|
138
|
-
if (!res.ok)
|
|
139
|
-
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const hint = res.status === 403
|
|
149
|
+
? " (an expired presigned URL, a revoked permission, or a template generated before the " +
|
|
150
|
+
"ForwarderRole granted s3:ListBucket — S3 answers 403 even for a MISSING first-deploy " +
|
|
151
|
+
"snapshot without it; regenerate with `fastagent deploy agentcore --force` and redeploy)"
|
|
152
|
+
: "";
|
|
153
|
+
throw new Error(`state snapshot GET failed: ${res.status}${hint}`);
|
|
154
|
+
}
|
|
140
155
|
const written = await unpackIntoStateRoot(stateRoot, Buffer.from(await res.arrayBuffer()));
|
|
141
156
|
log.info(`[agentcore] restored ${written} state file(s) from the snapshot`);
|
|
142
157
|
restored = true;
|
package/dist/channels/control.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
523
|
+
for (const entry of replay) {
|
|
496
524
|
let line;
|
|
497
525
|
try {
|
|
498
526
|
line = renderEntry(entry);
|
|
@@ -13,3 +13,16 @@ export interface DeployOptions {
|
|
|
13
13
|
input?: boolean;
|
|
14
14
|
}
|
|
15
15
|
export declare function runDeploy(host: DeployHost, dirArg: string, opts: DeployOptions): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
|
|
18
|
+
* never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
|
|
19
|
+
* machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
|
|
20
|
+
* command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
|
|
21
|
+
*/
|
|
22
|
+
export declare function writeArtifacts(target: string, artifacts: {
|
|
23
|
+
path: string;
|
|
24
|
+
content: string;
|
|
25
|
+
}[], options: {
|
|
26
|
+
force: boolean;
|
|
27
|
+
alwaysWrite?: string[];
|
|
28
|
+
}): Promise<void>;
|
|
@@ -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).
|
|
@@ -413,7 +414,13 @@ function isOurArtifact(path, content) {
|
|
|
413
414
|
return isGeneratedAgentcoreTemplate(content);
|
|
414
415
|
return false;
|
|
415
416
|
}
|
|
416
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
|
|
419
|
+
* never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
|
|
420
|
+
* machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
|
|
421
|
+
* command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
|
|
422
|
+
*/
|
|
423
|
+
export async function writeArtifacts(target, artifacts, options) {
|
|
417
424
|
for (const a of artifacts) {
|
|
418
425
|
const abs = join(target, a.path);
|
|
419
426
|
// Pure build output, not operator-owned configuration. It must track the generated template/runbook.
|
|
@@ -599,6 +606,11 @@ async function runDeployAgentcore(params) {
|
|
|
599
606
|
console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
|
|
600
607
|
if (outcome.url)
|
|
601
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
|
+
}
|
|
602
614
|
console.error(`[fastagent] invoke: aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ${outcome.runtimeArn} \\\n` +
|
|
603
615
|
` --runtime-session-id "my-conversation-000000000000000000" \\\n` +
|
|
604
616
|
` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -12,9 +12,10 @@ import { setLogLevel } from "../../log.js";
|
|
|
12
12
|
import { logAgentLoop } from "../../observe.js";
|
|
13
13
|
import { installProxyFetch } from "../../proxy.js";
|
|
14
14
|
import { workspaceHint } from "../../paths.js";
|
|
15
|
+
import { bindAddress } from "../../bind.js";
|
|
15
16
|
import { failStartup, placementOrExit } from "../fail.js";
|
|
16
|
-
import { maybeTunnel, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
|
|
17
|
-
import { parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
|
|
17
|
+
import { assertTunnelBindable, maybeTunnel, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
|
|
18
|
+
import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
|
|
18
19
|
export async function runDev(dirArg, opts) {
|
|
19
20
|
const dir = resolve(dirArg);
|
|
20
21
|
const placement = placementOrExit(dir);
|
|
@@ -33,12 +34,16 @@ export async function runDev(dirArg, opts) {
|
|
|
33
34
|
await serveOnce(dir, opts);
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
|
-
parsePort(opts.port, "--port", "flag"); // flag-shape
|
|
37
|
+
parsePort(opts.port, "--port", "flag"); // flag-shape checks before spawning
|
|
38
|
+
// The --bind/--tunnel conflict is decidable from flags alone: refuse it HERE, before a worker and a
|
|
39
|
+
// tunnel exist. The worker repeats the check because `http.host` can supply the address instead.
|
|
40
|
+
assertTunnelBindable(parseBind(opts.bind), opts.tunnel ?? false, "flag");
|
|
37
41
|
await runDevSupervisor(placement, { tunnel: opts.tunnel ?? false });
|
|
38
42
|
}
|
|
39
43
|
/** Assemble the agent and serve it once (the dev worker; also the --no-watch path). */
|
|
40
44
|
async function serveOnce(dir, opts) {
|
|
41
45
|
const portFlag = parsePort(opts.port, "--port", "flag");
|
|
46
|
+
const bindFlag = parseBind(opts.bind);
|
|
42
47
|
loadDotEnv(placementOrExit(dir).agentDir);
|
|
43
48
|
installProxyFetch();
|
|
44
49
|
const a = await createPiAgentFromDir(dir, {
|
|
@@ -57,12 +62,18 @@ async function serveOnce(dir, opts) {
|
|
|
57
62
|
// out in start (level info), keeping end-user content out of production logs. Wired in both postures.
|
|
58
63
|
const traced = logAgentLoop(a.agent);
|
|
59
64
|
const routed = await routesFor(a.agentDir, traced, a.stateRoot, a.sessionControl).catch(failStartup);
|
|
65
|
+
// `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
|
|
66
|
+
// configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
|
|
67
|
+
const configured = a.config.http?.host;
|
|
68
|
+
const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
|
|
69
|
+
assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
|
|
60
70
|
const withControl = mountSessionControl(routed.routes, a.sessionControl, a.stateRoot, {
|
|
61
71
|
tunnel: opts.tunnel ?? false,
|
|
62
72
|
agent: traced, // the remote data plane (POST /control/invoke) drives the SAME traced agent
|
|
73
|
+
host,
|
|
63
74
|
});
|
|
64
75
|
await startSchedules(a.agentDir, traced, a.stateRoot, a.config.selfSchedule ?? false);
|
|
65
|
-
serve({ ...routed, routes: withControl.routes }, portFlag ?? a.config.http?.port ?? 8787, (p) => {
|
|
76
|
+
serve({ ...routed, routes: withControl.routes }, { port: portFlag ?? a.config.http?.port ?? 8787, host }, (p) => {
|
|
66
77
|
withControl.announce(p);
|
|
67
78
|
maybeTunnel(a.agentDir, routed.routeChannels, p, opts.tunnel ?? false, a.stateRoot);
|
|
68
79
|
});
|
|
@@ -24,7 +24,7 @@ export async function runInfo(dirArg, opts) {
|
|
|
24
24
|
// tool), is isolated the same way everywhere (G2): info, dev, AND start report it and keep going with
|
|
25
25
|
// the tools that loaded. The `error`/`.catch` below only fires for a whole-load fault (an unreadable
|
|
26
26
|
// tools/ dir), not a single bad file.
|
|
27
|
-
const tools = await resolveAgentTools(config, agentDir
|
|
27
|
+
const tools = await resolveAgentTools(config, agentDir)
|
|
28
28
|
.then((r) => ({
|
|
29
29
|
names: r.toolNames,
|
|
30
30
|
deferred: r.deferredToolNames,
|
|
@@ -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
|
+
}
|
|
@@ -17,14 +17,16 @@ import { setWakeupsSink } from "../../schedule/wakeups.js";
|
|
|
17
17
|
import { logAgentLoop } from "../../observe.js";
|
|
18
18
|
import { installProxyFetch } from "../../proxy.js";
|
|
19
19
|
import { exists } from "../../paths.js";
|
|
20
|
+
import { bindAddress } from "../../bind.js";
|
|
20
21
|
import { failStartup, placementOrExit } from "../fail.js";
|
|
21
|
-
import { maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
|
|
22
|
-
import { parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
|
|
22
|
+
import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
|
|
23
|
+
import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
|
|
23
24
|
export async function runStart(dirArg, opts) {
|
|
24
25
|
const dir = resolve(dirArg);
|
|
25
26
|
// Flag validation first: a bad --port is a USAGE error (exit 2), and reporting it must not depend on
|
|
26
27
|
// the directory being an agent (which is a runtime/environment failure, exit 1).
|
|
27
28
|
const portFlag = parsePort(opts.port, "--port", "flag");
|
|
29
|
+
const bindFlag = parseBind(opts.bind);
|
|
28
30
|
const placement = placementOrExit(dir);
|
|
29
31
|
setLogLevel("info"); // production posture: info+, the debug turn trace (and its end-user content) gated out
|
|
30
32
|
loadDotEnv(placement.agentDir);
|
|
@@ -88,9 +90,15 @@ export async function runStart(dirArg, opts) {
|
|
|
88
90
|
// Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
|
|
89
91
|
const traced = logAgentLoop(agent);
|
|
90
92
|
const routed = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: !agentcore }).catch(failStartup);
|
|
93
|
+
// `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
|
|
94
|
+
// configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
|
|
95
|
+
const configured = config.http?.host;
|
|
96
|
+
const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
|
|
97
|
+
assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
|
|
91
98
|
const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
|
|
92
99
|
tunnel: opts.tunnel ?? false,
|
|
93
100
|
agent: traced,
|
|
101
|
+
host,
|
|
94
102
|
});
|
|
95
103
|
// AgentCore + selfSchedule: register the wake-ALARM sink BEFORE the scheduler starts — the boot
|
|
96
104
|
// wake pump may advance a recurring entry (a store save) and that save must already re-arm its
|
|
@@ -126,7 +134,7 @@ export async function runStart(dirArg, opts) {
|
|
|
126
134
|
}
|
|
127
135
|
log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
|
|
128
136
|
}
|
|
129
|
-
serve({ ...routed, routes }, portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, (p) => {
|
|
137
|
+
serve({ ...routed, routes }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
|
|
130
138
|
withControl.announce(p);
|
|
131
139
|
maybeTunnel(agentDir, routed.routeChannels, p, opts.tunnel ?? false, stateRoot);
|
|
132
140
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** `fastagent tool <name> '<json>' [dir]`: run one tool's body directly with JSON args — no model. */
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
3
4
|
import { loadDotEnv } from "../../env.js";
|
|
4
5
|
import { loadConfig } from "../../engines/pi/config.js";
|
|
5
6
|
import { resolveAgentTools } from "../../engines/pi/create.js";
|
|
@@ -16,7 +17,7 @@ export async function runTool(name, argsJson, dirArg) {
|
|
|
16
17
|
// The same tool set dev/start mount (defaults + config.tools + discovered, deduped), so the runner
|
|
17
18
|
// exercises exactly what gets served — a shadowed tool is surfaced, not silently run. Resolve the
|
|
18
19
|
// placement like the openers, so `fastagent tool` finds the SAME tools/ as dev/start.
|
|
19
|
-
const { tools, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir
|
|
20
|
+
const { tools, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir).catch(failStartup);
|
|
20
21
|
for (const c of toolCollisions) {
|
|
21
22
|
console.error(`[fastagent] warn: tool "${c.name}" (${c.source}) is shadowed by a default/config tool — not mounted`);
|
|
22
23
|
}
|
|
@@ -25,7 +26,14 @@ export async function runTool(name, argsJson, dirArg) {
|
|
|
25
26
|
if (!tool) {
|
|
26
27
|
failStartup(new Error(`unknown tool "${name}". available: ${tools.map((t) => t.name).join(", ") || "(none)"}`));
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
+
// Two contexts, because the two tool families read different ones and this command must serve both
|
|
30
|
+
// exactly as serving does: fastagent's own tools take cwd/session/activation from `turnContext`
|
|
31
|
+
// (AsyncLocalStorage), and pi's default coding tools take the ExecutionEnv from the harness's TOOL
|
|
32
|
+
// context — which no harness supplies here, so this stands in for it, rooted at the same workspace.
|
|
33
|
+
const env = new NodeExecutionEnv({ cwd: workspace });
|
|
34
|
+
const result = await turnContext
|
|
35
|
+
.run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args, undefined, undefined, { env }))
|
|
36
|
+
.catch(failStartup);
|
|
29
37
|
const out = result?.details !== undefined
|
|
30
38
|
? result.details
|
|
31
39
|
: (result?.content ?? []).map((c) => ("text" in c ? c.text : "")).join("");
|
package/dist/cli/program.js
CHANGED
|
@@ -27,6 +27,10 @@ const NO_INPUT = {
|
|
|
27
27
|
description: "never prompt (CI/scripts) — missing information becomes an error instead of a question",
|
|
28
28
|
};
|
|
29
29
|
const PORT = { flags: "--port <n>", description: "HTTP port" };
|
|
30
|
+
const BIND = {
|
|
31
|
+
flags: "--bind <addr>",
|
|
32
|
+
description: "bind address (default: all interfaces; 127.0.0.1 keeps it off the LAN)",
|
|
33
|
+
};
|
|
30
34
|
const TUNNEL = {
|
|
31
35
|
flags: "--tunnel",
|
|
32
36
|
description: "expose a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared) and auto-register " +
|
|
@@ -85,6 +89,7 @@ const dev = {
|
|
|
85
89
|
args: [DIR_ARG],
|
|
86
90
|
flags: [
|
|
87
91
|
PORT,
|
|
92
|
+
BIND,
|
|
88
93
|
MODEL,
|
|
89
94
|
AUTH_PATH,
|
|
90
95
|
{ flags: "--no-watch", description: "serve once, no file-watching" },
|
|
@@ -97,6 +102,7 @@ const dev = {
|
|
|
97
102
|
],
|
|
98
103
|
run: async (args, f) => (await import("./commands/dev.js")).runDev(args[0], {
|
|
99
104
|
port: f.port,
|
|
105
|
+
bind: f.bind,
|
|
100
106
|
model: f.model,
|
|
101
107
|
authPath: f.authPath,
|
|
102
108
|
watch: f.watch !== false,
|
|
@@ -218,6 +224,7 @@ const start = {
|
|
|
218
224
|
args: [DIR_ARG],
|
|
219
225
|
flags: [
|
|
220
226
|
PORT,
|
|
227
|
+
BIND,
|
|
221
228
|
MODEL,
|
|
222
229
|
{ flags: "--sessions-dir <dir>", description: "sessions directory override" },
|
|
223
230
|
AUTH_PATH,
|
|
@@ -230,6 +237,7 @@ const start = {
|
|
|
230
237
|
],
|
|
231
238
|
notes: "Precedence chains:\n" +
|
|
232
239
|
" port: --port > PORT env > fastagent.config.ts http.port > 8787\n" +
|
|
240
|
+
" bind: --bind > fastagent.config.ts http.host > all interfaces\n" +
|
|
233
241
|
" state: FASTAGENT_STATE_DIR > <agent dir>/.state — mutable machine state\n" +
|
|
234
242
|
" (sessions, channel state, schedule state); point it at a mounted\n" +
|
|
235
243
|
" volume so a redeploy that replaces the directory never wipes it\n" +
|
|
@@ -240,6 +248,7 @@ const start = {
|
|
|
240
248
|
" share one credential across projects)",
|
|
241
249
|
run: async (args, f) => (await import("./commands/start.js")).runStart(args[0], {
|
|
242
250
|
port: f.port,
|
|
251
|
+
bind: f.bind,
|
|
243
252
|
model: f.model,
|
|
244
253
|
sessionsDir: f.sessionsDir,
|
|
245
254
|
authPath: f.authPath,
|
|
@@ -444,6 +453,31 @@ const schedule = {
|
|
|
444
453
|
},
|
|
445
454
|
],
|
|
446
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
|
+
};
|
|
447
481
|
const login = {
|
|
448
482
|
name: "login",
|
|
449
483
|
summary: "authenticate a model provider (subscription/OAuth or API key)",
|
|
@@ -477,6 +511,7 @@ export const specs = [
|
|
|
477
511
|
start,
|
|
478
512
|
add,
|
|
479
513
|
deploy,
|
|
514
|
+
logs,
|
|
480
515
|
login,
|
|
481
516
|
];
|
|
482
517
|
/**
|