@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.
- package/dist/channels/agentcore-state.js +7 -0
- package/dist/channels/agentcore.js +24 -5
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +5 -1
- package/dist/cli/commands/attach.d.ts +29 -1
- package/dist/cli/commands/attach.js +76 -4
- package/dist/cli/commands/deploy.js +11 -5
- package/dist/cli/commands/dev.js +2 -2
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/logs.d.ts +6 -0
- package/dist/cli/commands/logs.js +27 -0
- package/dist/cli/commands/start.js +2 -2
- package/dist/cli/program.js +26 -0
- package/dist/deploy/agentcore/logs.d.ts +35 -0
- package/dist/deploy/agentcore/logs.js +112 -0
- package/dist/deploy/agentcore/plan.d.ts +26 -2
- package/dist/deploy/agentcore/plan.js +45 -6
- package/dist/deploy/container.js +6 -3
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +6 -1
- package/dist/engines/pi/create.js +16 -19
- package/dist/engines/pi/definition.d.ts +15 -0
- package/dist/engines/pi/definition.js +22 -1
- package/dist/engines/pi/harness.d.ts +12 -28
- package/dist/engines/pi/harness.js +21 -71
- package/dist/engines/pi/open.d.ts +1 -1
- package/dist/engines/pi/open.js +20 -0
- package/dist/engines/pi/report.d.ts +16 -0
- package/dist/engines/pi/report.js +30 -0
- package/dist/engines/pi/session-builder.js +2 -2
- package/dist/engines/pi/session-control.d.ts +23 -4
- package/dist/engines/pi/session-control.js +159 -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/session-remote.js +17 -0
- package/dist/session.d.ts +54 -9
- package/package.json +1 -1
|
@@ -29,19 +29,42 @@
|
|
|
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
|
|
41
63
|
* activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
|
|
42
64
|
* platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
|
|
43
|
-
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy
|
|
44
|
-
*
|
|
65
|
+
* for anyone who returns after a longer gap. `/ping` reports HealthyBusy + time_of_last_update while
|
|
66
|
+
* work is in flight (the FIELD is what the platform's idle measurement actually reads — agentcore.ts),
|
|
67
|
+
* so this timer only ever starts once the agent has genuinely settled — a long turn is never cut short.
|
|
45
68
|
* AWS accepts 60–28800.
|
|
46
69
|
*/
|
|
47
70
|
export const IDLE_TIMEOUT_SECONDS = 180;
|
|
@@ -72,6 +95,13 @@ export const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agent
|
|
|
72
95
|
export function isGeneratedAgentcoreTemplate(content) {
|
|
73
96
|
return content.startsWith(GENERATED_TEMPLATE_MARKER);
|
|
74
97
|
}
|
|
98
|
+
/** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
|
|
99
|
+
export function agentcoreName(workspaceBasename) {
|
|
100
|
+
return (workspaceBasename
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "") || "agent");
|
|
104
|
+
}
|
|
75
105
|
/** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
|
|
76
106
|
export function toRuntimeName(basename) {
|
|
77
107
|
const slug = basename.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
@@ -437,6 +467,10 @@ function template(input, translated) {
|
|
|
437
467
|
` PORT: "8080"`, // the Runtime service contract's fixed port (config.http.port does not apply here)
|
|
438
468
|
` FASTAGENT_AGENTCORE: "1"`, // serve mounts /invocations + /ping, arms no resident cron
|
|
439
469
|
` FASTAGENT_STATE_DIR: ${MOUNT}`,
|
|
470
|
+
// Inside the state root on purpose — the snapshot is this host's only durable store, and it copies
|
|
471
|
+
// exactly one tree. See {@link SECRETS_DIR}: the sibling layout every other host uses would leave a
|
|
472
|
+
// rotated OAuth credential outside it, i.e. discarded with the microVM.
|
|
473
|
+
` FASTAGENT_SECRETS_DIR: ${SECRETS_DIR}`,
|
|
440
474
|
];
|
|
441
475
|
// The auth seed is chunked (env values max 2048 chars — see AUTH_SEED_CHUNK_SIZE): N parameters,
|
|
442
476
|
// each riding its own env var; `start` reassembles them (collectAuthSeed). Empty defaults = unused.
|
|
@@ -529,7 +563,7 @@ function template(input, translated) {
|
|
|
529
563
|
` # forces a NAT gateway for model/channel egress (~$33/mo) — deliberately not the default.`,
|
|
530
564
|
` FilesystemConfigurations:`,
|
|
531
565
|
` - SessionStorage: { MountPath: ${MOUNT} }`,
|
|
532
|
-
` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy keeps BUSY sessions alive
|
|
566
|
+
` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy + time_of_last_update keeps BUSY sessions alive), max compute`,
|
|
533
567
|
` # lifetime ${MAX_LIFETIME_SECONDS}s — the platform ceiling; the session id stays valid, so the next invoke`,
|
|
534
568
|
` # just gets fresh compute with the same storage. Memory bills per second for the whole`,
|
|
535
569
|
` # session INCLUDING the idle tail, so a shorter tail is the main cost lever here.`,
|
|
@@ -679,7 +713,12 @@ export function planAgentcoreDeploy(input) {
|
|
|
679
713
|
? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
|
|
680
714
|
: `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
|
|
681
715
|
? []
|
|
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"`
|
|
716
|
+
: [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`, ``, `# 5. Tail the Runtime's application stdout/stderr (same fastagent messages + log level as locally).`, `# Discovery resolves the per-endpoint log group from the stack's RuntimeArn:`, `fastagent logs agentcore --follow`, ...(needsForwarder
|
|
717
|
+
? [
|
|
718
|
+
`# The ingress transport is a separate Lambda and therefore a separate log source:`,
|
|
719
|
+
`fastagent logs agentcore --source forwarder --follow`,
|
|
720
|
+
]
|
|
721
|
+
: []));
|
|
683
722
|
// Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored
|
|
684
723
|
// can't be read at plan time — `--run` carries it as FastagentAuthSeed.
|
|
685
724
|
if (!isEnvKey(input.modelAuth)) {
|
|
@@ -694,7 +733,7 @@ export function planAgentcoreDeploy(input) {
|
|
|
694
733
|
post.push(`# Register the Telegram webhook (default route POST /telegram; secret_token MUST equal TELEGRAM_SECRET_TOKEN):`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=<ForwarderUrl>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
|
|
695
734
|
}
|
|
696
735
|
if (channels.includes("github")) {
|
|
697
|
-
post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy holds the session while turns run, but the 8 h compute ceiling is hard).`);
|
|
736
|
+
post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
|
|
698
737
|
}
|
|
699
738
|
if (channels.includes("slack")) {
|
|
700
739
|
post.push(`# Set Slack Event Subscriptions → Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
|
|
@@ -716,6 +755,6 @@ export function planAgentcoreDeploy(input) {
|
|
|
716
755
|
if (needsForwarder) {
|
|
717
756
|
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
757
|
}
|
|
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.)
|
|
758
|
+
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
759
|
return { artifacts, runbook, untranslatableSchedules: untranslatable };
|
|
721
760
|
}
|
package/dist/deploy/container.js
CHANGED
|
@@ -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 —
|
|
132
|
-
* store
|
|
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 {
|
|
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 "./
|
|
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,9 +17,9 @@ 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
|
-
import {
|
|
22
|
+
import { reportFindingsIfChanged } from "./report.js";
|
|
23
23
|
import { inMemorySessionStore } from "./sessions.js";
|
|
24
24
|
import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
|
|
25
25
|
import { withSearchTool } from "./search-tools.js";
|
|
@@ -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:
|
|
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
|
/**
|
|
@@ -215,12 +221,6 @@ export function createPiAgent(options) {
|
|
|
215
221
|
observer: options.observer,
|
|
216
222
|
});
|
|
217
223
|
}
|
|
218
|
-
/** Stable identity of a definition's non-fatal findings, for change-detection in `live` (dedup only). */
|
|
219
|
-
function findingsSignature(def) {
|
|
220
|
-
const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
|
|
221
|
-
const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
|
|
222
|
-
return [...collisions, ...diagnostics].sort().join("\n");
|
|
223
|
-
}
|
|
224
224
|
/**
|
|
225
225
|
* L2: "point at a directory → agent": load + assemble (base + AGENTS.md + skills + env) + L1 in one
|
|
226
226
|
* call. Returns the definition so callers can surface diagnostics/collisions.
|
|
@@ -233,10 +233,11 @@ export async function createPiAgentFromDefinition(dir, options) {
|
|
|
233
233
|
// Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to
|
|
234
234
|
// report (skills/diagnostics/collisions). Serving does NOT close over it — see `live` below.
|
|
235
235
|
const definition = await loadAgentDefinition(dir, { cwd: env.cwd, env });
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
236
|
+
// Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by
|
|
237
|
+
// the resolved dir): announced once here, and re-announced by a turn or by the control plane's
|
|
238
|
+
// command list only when the set CHANGES — a runtime-written bad skill surfaces the moment it
|
|
239
|
+
// appears, a static one does not spam. Log dedup, not session state (stateless invoke holds).
|
|
240
|
+
reportFindingsIfChanged(definition.dir, definition);
|
|
240
241
|
// Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
|
|
241
242
|
// it; a caller's own search_tools wins).
|
|
242
243
|
const tools = withSearchTool(options.tools ?? piDefaultTools());
|
|
@@ -259,11 +260,7 @@ export async function createPiAgentFromDefinition(dir, options) {
|
|
|
259
260
|
// next good edit heals both.
|
|
260
261
|
live: async () => {
|
|
261
262
|
const def = await loadAgentDefinition(dir, { cwd: env.cwd, env });
|
|
262
|
-
|
|
263
|
-
if (sig !== reportedFindings) {
|
|
264
|
-
reportedFindings = sig;
|
|
265
|
-
reportDefinitionWarnings(def.collisions, def.diagnostics);
|
|
266
|
-
}
|
|
263
|
+
reportFindingsIfChanged(def.dir, def);
|
|
267
264
|
return {
|
|
268
265
|
systemPrompt: assembleSystemPrompt({
|
|
269
266
|
// Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity,
|
|
@@ -41,6 +41,21 @@ export interface LoadAgentDefinitionOptions {
|
|
|
41
41
|
}
|
|
42
42
|
/** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
|
|
43
43
|
export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
|
|
44
|
+
/**
|
|
45
|
+
* The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
|
|
46
|
+
* loader, same containment guard, same first-wins collision rule) — for readers that need only the
|
|
47
|
+
* names and must not pay the full load's ② context walk (every AGENTS.md from cwd to root) for them.
|
|
48
|
+
* The control plane's `commands()` is that reader, called when a composer opens its completion list.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadAgentSkills(agentDir: string, options?: {
|
|
51
|
+
cwd?: string;
|
|
52
|
+
env?: ExecutionEnv;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
skills: Skill[];
|
|
55
|
+
diagnostics: SkillDiagnostic[];
|
|
56
|
+
collisions: SkillCollision[];
|
|
57
|
+
dir: string;
|
|
58
|
+
}>;
|
|
44
59
|
/**
|
|
45
60
|
* Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
|
|
46
61
|
* this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
|
|
@@ -42,6 +42,11 @@ export async function loadAgentDefinition(agentDir, options = {}) {
|
|
|
42
42
|
throw new Error(`cannot read ${personaPath}: ${personaRead.error.message}`);
|
|
43
43
|
}
|
|
44
44
|
const persona = personaRead.ok ? personaRead.value : undefined;
|
|
45
|
+
const { skills, diagnostics, collisions } = await readSkills(e, root);
|
|
46
|
+
return { contextFiles, persona, skills, diagnostics, collisions, dir: root };
|
|
47
|
+
}
|
|
48
|
+
/** The skills half, shared by the full load and {@link loadAgentSkills}. `root` is already resolved. */
|
|
49
|
+
async function readSkills(e, root) {
|
|
45
50
|
// Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
|
|
46
51
|
// definition loads the same skills on every machine — and, like tools/channels/schedules, a symlink
|
|
47
52
|
// that escapes the agent dir is refused rather than followed (the fourth of four surfaces).
|
|
@@ -58,7 +63,23 @@ export async function loadAgentDefinition(agentDir, options = {}) {
|
|
|
58
63
|
byName.set(skill.name, skill);
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
|
-
return {
|
|
66
|
+
return { skills: [...byName.values()], diagnostics, collisions };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
|
|
70
|
+
* loader, same containment guard, same first-wins collision rule) — for readers that need only the
|
|
71
|
+
* names and must not pay the full load's ② context walk (every AGENTS.md from cwd to root) for them.
|
|
72
|
+
* The control plane's `commands()` is that reader, called when a composer opens its completion list.
|
|
73
|
+
*/
|
|
74
|
+
export async function loadAgentSkills(agentDir, options = {}) {
|
|
75
|
+
const cwd = options.cwd ?? agentDir;
|
|
76
|
+
const e = options.env ?? new NodeExecutionEnv({ cwd });
|
|
77
|
+
const rootResult = await e.absolutePath(agentDir);
|
|
78
|
+
if (!rootResult.ok)
|
|
79
|
+
throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
|
|
80
|
+
// `dir` is the RESOLVED root, like {@link LoadedDefinition.dir}: readers key per-definition state
|
|
81
|
+
// (the findings memo) on it, and "./agent" vs an absolute path must not become two definitions.
|
|
82
|
+
return { ...(await readSkills(e, rootResult.value)), dir: rootResult.value };
|
|
62
83
|
}
|
|
63
84
|
/**
|
|
64
85
|
* Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { AgentHarness } from "@earendil-works/pi-agent-core";
|
|
10
10
|
import type { ExecutionEnv, ExecutionToolContext, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
11
11
|
import type { Model, Models } from "@earendil-works/pi-ai";
|
|
12
|
-
import type
|
|
12
|
+
import { type PiSessionStore } from "./sessions.ts";
|
|
13
13
|
import { type MountedTool } from "./tool.ts";
|
|
14
|
+
import { type OverrideEntryLike } from "./session-settings.ts";
|
|
14
15
|
/**
|
|
15
16
|
* The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
|
|
16
17
|
* tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
|
|
@@ -84,36 +85,19 @@ export declare const SUMMARIZATION_RETRY_POLICY: {
|
|
|
84
85
|
readonly maxRetries: 3;
|
|
85
86
|
readonly baseDelayMs: 2000;
|
|
86
87
|
};
|
|
87
|
-
export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
|
|
88
|
-
/** The shape both override consumers walk — a session entry, structurally. */
|
|
89
|
-
export interface OverrideEntryLike {
|
|
90
|
-
type: string;
|
|
91
|
-
provider?: string;
|
|
92
|
-
modelId?: string;
|
|
93
|
-
thinkingLevel?: string;
|
|
94
|
-
}
|
|
95
88
|
/**
|
|
96
|
-
* The
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
89
|
+
* The serving default for reasoning effort, pinned to what pi's TUI defaults to (its
|
|
90
|
+
* DEFAULT_THINKING_LEVEL) — NOT inherited from the bare harness, whose own fallback is "off": an
|
|
91
|
+
* author vibes at "medium" in pi and must get "medium" when served (fidelity), and pinning the value
|
|
92
|
+
* here means an upstream default change in either place cannot silently alter deployments. Models
|
|
93
|
+
* that don't support a level are clamped by pi per model.
|
|
101
94
|
*/
|
|
102
|
-
export declare
|
|
103
|
-
model?: {
|
|
104
|
-
provider: string;
|
|
105
|
-
modelId: string;
|
|
106
|
-
};
|
|
107
|
-
thinkingLevel?: string;
|
|
108
|
-
};
|
|
95
|
+
export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
|
|
109
96
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
|
|
115
|
-
* back to the default with a deduped warn (fail visibly without bricking the session — the
|
|
116
|
-
* conversation must survive a registry change across deploys); an unknown thinking level likewise.
|
|
97
|
+
* {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
|
|
98
|
+
* being executable with no control-plane command involved (pi appends these entries itself; a
|
|
99
|
+
* deployment's configured model can change between restarts). Deduped per session+cause — it would
|
|
100
|
+
* otherwise repeat every turn.
|
|
117
101
|
*/
|
|
118
102
|
export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], models: Models, defaults: {
|
|
119
103
|
model: AnyModel;
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { AgentHarness } from "@earendil-works/pi-agent-core";
|
|
10
10
|
import { log } from "../../log.js";
|
|
11
|
+
import { activePathEntries } from "./sessions.js";
|
|
11
12
|
import { isDeferredTool } from "./tool.js";
|
|
13
|
+
import { resolveSessionSettings } from "./session-settings.js";
|
|
12
14
|
/**
|
|
13
15
|
* The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
|
|
14
16
|
* tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
|
|
@@ -48,7 +50,7 @@ export const SUMMARIZATION_RETRY_POLICY = { enabled: true, maxRetries: 3, baseDe
|
|
|
48
50
|
* here means an upstream default change in either place cannot silently alter deployments. Models
|
|
49
51
|
* that don't support a level are clamped by pi per model.
|
|
50
52
|
*/
|
|
51
|
-
const DEFAULT_THINKING_LEVEL = "medium";
|
|
53
|
+
export const DEFAULT_THINKING_LEVEL = "medium";
|
|
52
54
|
/**
|
|
53
55
|
* Resolve the active-tool set for a fresh harness — the ONE place both fallbacks live. pi's harness
|
|
54
56
|
* WRITES active-tool changes to the session (`setActiveTools` → `active_tools_change`) but its
|
|
@@ -72,83 +74,30 @@ const DEFAULT_THINKING_LEVEL = "medium";
|
|
|
72
74
|
* (like L2's findings memo), not session state — the resolve stays derived from the session.
|
|
73
75
|
*/
|
|
74
76
|
const warnedRestores = new Set();
|
|
75
|
-
/** pi's ThinkingLevel scale as a checkable set — THE single source for fastagent (session entries
|
|
76
|
-
* store plain strings; session-control's dispatch validation and capabilities derive from this).
|
|
77
|
-
* The `satisfies Record<ThinkingLevel, …>` anchor makes it EXHAUSTIVE against pi's union: pi
|
|
78
|
-
* adding a level turns this into a type error instead of a silent drift where `set_thinking`
|
|
79
|
-
* rejects a value pi supports. */
|
|
80
|
-
const ALL_THINKING_LEVELS = {
|
|
81
|
-
off: true,
|
|
82
|
-
minimal: true,
|
|
83
|
-
low: true,
|
|
84
|
-
medium: true,
|
|
85
|
-
high: true,
|
|
86
|
-
xhigh: true,
|
|
87
|
-
max: true,
|
|
88
|
-
};
|
|
89
|
-
export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
|
|
90
77
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* which record is "the" override.
|
|
96
|
-
*/
|
|
97
|
-
export function lastOverrideEntries(entries) {
|
|
98
|
-
let model;
|
|
99
|
-
let modelSeen = false;
|
|
100
|
-
let thinkingLevel;
|
|
101
|
-
let thinkingSeen = false;
|
|
102
|
-
for (let i = entries.length - 1; i >= 0 && !(modelSeen && thinkingSeen); i--) {
|
|
103
|
-
const e = entries[i];
|
|
104
|
-
if (!modelSeen && e?.type === "model_change") {
|
|
105
|
-
modelSeen = true;
|
|
106
|
-
if (e.provider !== undefined && e.modelId !== undefined)
|
|
107
|
-
model = { provider: e.provider, modelId: e.modelId };
|
|
108
|
-
}
|
|
109
|
-
if (!thinkingSeen && e?.type === "thinking_level_change") {
|
|
110
|
-
thinkingSeen = true;
|
|
111
|
-
if (e.thinkingLevel !== undefined)
|
|
112
|
-
thinkingLevel = e.thinkingLevel;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return { model, thinkingLevel };
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Resolve the session's model/thinking OVERRIDES for a fresh harness — same shape as the
|
|
119
|
-
* active-tools resolve above: pi writes `model_change`/`thinking_level_change` entries on explicit
|
|
120
|
-
* setModel/setThinkingLevel (the control plane's `set_model`/`set_thinking` append them directly)
|
|
121
|
-
* but a fresh harness never reads them back. Override facts come from {@link lastOverrideEntries};
|
|
122
|
-
* this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
|
|
123
|
-
* back to the default with a deduped warn (fail visibly without bricking the session — the
|
|
124
|
-
* conversation must survive a registry change across deploys); an unknown thinking level likewise.
|
|
78
|
+
* {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
|
|
79
|
+
* being executable with no control-plane command involved (pi appends these entries itself; a
|
|
80
|
+
* deployment's configured model can change between restarts). Deduped per session+cause — it would
|
|
81
|
+
* otherwise repeat every turn.
|
|
125
82
|
*/
|
|
126
83
|
export function resolveHarnessOverrides(entries, models, defaults, sessionId) {
|
|
127
|
-
|
|
128
|
-
let thinkingLevel = defaults.thinkingLevel;
|
|
84
|
+
const settings = resolveSessionSettings(entries, models, defaults);
|
|
129
85
|
const warnOnce = (key, message) => {
|
|
130
86
|
const emit = warnedRestores.has(key) ? log.debug : log.warn;
|
|
131
87
|
warnedRestores.add(key);
|
|
132
88
|
emit(message);
|
|
133
89
|
};
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
if (found)
|
|
138
|
-
model = found;
|
|
139
|
-
else {
|
|
140
|
-
warnOnce(`${sessionId}\u0000model\u0000${recorded.model.provider}/${recorded.model.modelId}`, `[fastagent] session ${sessionId}: recorded model override ${recorded.model.provider}/${recorded.model.modelId} is not in this deployment's registry — using the configured default`);
|
|
141
|
-
}
|
|
90
|
+
const dropped = settings.dropped;
|
|
91
|
+
if (dropped?.model) {
|
|
92
|
+
warnOnce(`${sessionId}\u0000model\u0000${dropped.model}`, `[fastagent] session ${sessionId}: recorded model override ${dropped.model} is not in this deployment's registry — using the configured default`);
|
|
142
93
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
warnOnce(`${sessionId}\u0000thinking\u0000${recorded.thinkingLevel}`, `[fastagent] session ${sessionId}: recorded thinking level "${recorded.thinkingLevel}" is unknown — using the configured default`);
|
|
149
|
-
}
|
|
94
|
+
if (dropped?.thinkingLevel) {
|
|
95
|
+
const { recorded, running, known } = dropped.thinkingLevel;
|
|
96
|
+
warnOnce(`${sessionId}\u0000thinking\u0000${settings.model.provider}/${settings.model.id}\u0000${recorded}`, known
|
|
97
|
+
? `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is not supported by ${settings.model.provider}/${settings.model.id} — running at "${running}"`
|
|
98
|
+
: `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is unknown — using the configured default`);
|
|
150
99
|
}
|
|
151
|
-
return { model, thinkingLevel };
|
|
100
|
+
return { model: settings.model, thinkingLevel: settings.thinkingLevel };
|
|
152
101
|
}
|
|
153
102
|
export function resolveHarnessActiveToolNames(recorded, tools, sessionId) {
|
|
154
103
|
const anyDeferred = tools.some(isDeferredTool);
|
|
@@ -170,9 +119,10 @@ export function piHarnessFactory(options) {
|
|
|
170
119
|
return async (sessionId) => {
|
|
171
120
|
const session = await options.sessions.openOrCreate(sessionId);
|
|
172
121
|
// One extra entry walk per invoke to collect the activation deltas — negligible against the model
|
|
173
|
-
// call, same trade as L2's per-invoke definition re-read.
|
|
174
|
-
//
|
|
175
|
-
|
|
122
|
+
// call, same trade as L2's per-invoke definition re-read. The walk is over the ACTIVE PATH, not
|
|
123
|
+
// the flat journal: `navigate` moves the leaf, so the tree can hold an abandoned branch whose
|
|
124
|
+
// activations and overrides this session has left behind.
|
|
125
|
+
const entries = await activePathEntries(session);
|
|
176
126
|
const activated = entries.flatMap((e) => e.type === "custom" && e.customType === TOOL_ACTIVATION_ENTRY
|
|
177
127
|
? (e.data?.names ?? [])
|
|
178
128
|
: []);
|
|
@@ -4,7 +4,7 @@ import type { SessionControl } from "../../session.ts";
|
|
|
4
4
|
import type { SessionObserver } from "./invoke.ts";
|
|
5
5
|
import type { PiSessionReader, PiSessionStore } from "./sessions.ts";
|
|
6
6
|
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
7
|
-
import type
|
|
7
|
+
import { type LoadedDefinition } from "./definition.ts";
|
|
8
8
|
import type { ToolCollision } from "./tool.ts";
|
|
9
9
|
import type { MountedTool } from "./tool.ts";
|
|
10
10
|
export interface CreatePiAgentFromDirOptions {
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -14,6 +14,8 @@ import { resolveStateRoot, resolvePlacement } from "../../paths.js";
|
|
|
14
14
|
import { createPiAgentFromDefinition, resolveAgentTools } from "./create.js";
|
|
15
15
|
import { createPiSessionControl } from "./session-control.js";
|
|
16
16
|
import { withWakeTool } from "./wake-tool.js";
|
|
17
|
+
import { loadAgentSkills } from "./definition.js";
|
|
18
|
+
import { reportFindingsIfChanged } from "./report.js";
|
|
17
19
|
import { jsonlSessionStore } from "./sessions.js";
|
|
18
20
|
export async function resolveAgentAssembly(dir, options = {}) {
|
|
19
21
|
// Placement is structural (resolvePlacement): the AGENT DIR carries definition + config + machinery;
|
|
@@ -73,6 +75,23 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
73
75
|
? createPiSessionControl({
|
|
74
76
|
sessions,
|
|
75
77
|
boundary: () => boundaryParts,
|
|
78
|
+
// Skills ARE the names a client offers — the resolved set, after collisions were decided
|
|
79
|
+
// first-wins, which a client cannot reconstruct from the directory. Read LIVE (the directory
|
|
80
|
+
// is the agent: a skill added while serving is in play on the next turn, so it must be
|
|
81
|
+
// listable now) and SKILLS-ONLY: this is called when a composer opens its completion list,
|
|
82
|
+
// and the full load's ② context walk buys nothing here.
|
|
83
|
+
commands: async () => {
|
|
84
|
+
const loaded = await loadAgentSkills(agentDir, { cwd: workspace });
|
|
85
|
+
// A skill whose frontmatter broke simply is not in `skills` — it would disappear from the
|
|
86
|
+
// author's composer with no signal anywhere. The memo is SHARED with the turn path (keyed
|
|
87
|
+
// by dir), so a finding is warned when it appears, not once per reader that notices it.
|
|
88
|
+
reportFindingsIfChanged(loaded.dir, loaded);
|
|
89
|
+
return loaded.skills.map((skill) => ({
|
|
90
|
+
name: skill.name,
|
|
91
|
+
description: skill.description,
|
|
92
|
+
source: "skill",
|
|
93
|
+
}));
|
|
94
|
+
},
|
|
76
95
|
// The caller tap's boundary-event half: state_changed/compaction_* originate in the hub
|
|
77
96
|
// and never cross the data plane's observer seam — without this, an audit tap wired here
|
|
78
97
|
// would miss exactly the mutations it most needs to see (set_model).
|
|
@@ -102,6 +121,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
102
121
|
lease: parts.lease,
|
|
103
122
|
models: parts.models,
|
|
104
123
|
harnessFactory: parts.harnessFactory,
|
|
124
|
+
defaults: parts.defaults,
|
|
105
125
|
};
|
|
106
126
|
}
|
|
107
127
|
: undefined,
|
|
@@ -6,8 +6,24 @@ import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
|
6
6
|
import type { SkillCollision } from "./definition.ts";
|
|
7
7
|
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
8
8
|
import type { ToolCollision } from "./tool.ts";
|
|
9
|
+
type Findings = {
|
|
10
|
+
collisions: SkillCollision[];
|
|
11
|
+
diagnostics: SkillDiagnostic[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* THE door for definition findings: warns only when this dir's set CHANGED since the last report.
|
|
15
|
+
* Every reader calls it — boot, the per-turn live read, the control plane's command list — so a
|
|
16
|
+
* finding is announced when it appears and never repeated. There is deliberately no "record without
|
|
17
|
+
* printing" variant: a memo entry that trusts some other caller to have printed would silently
|
|
18
|
+
* swallow findings for a caller that does not.
|
|
19
|
+
*
|
|
20
|
+
* `dir` must be the RESOLVED definition root (`LoadedDefinition.dir`), or two spellings of one path
|
|
21
|
+
* become two memos and warn twice.
|
|
22
|
+
*/
|
|
23
|
+
export declare function reportFindingsIfChanged(dir: string, def: Findings): void;
|
|
9
24
|
export declare function reportDefinitionWarnings(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
|
|
10
25
|
export declare function reportToolCollisions(collisions: ToolCollision[]): void;
|
|
11
26
|
/** Report per-file module failures. The caller decides whether they are degradations (tools/schedules)
|
|
12
27
|
* or fatal (declared channels on the serving path). */
|
|
13
28
|
export declare function reportModuleLoadFailures(failures: ModuleLoadFailure[]): void;
|
|
29
|
+
export {};
|