@fastagent-sh/fastagent 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +25 -37
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
package/dist/deploy/fly/run.js
CHANGED
|
@@ -18,15 +18,70 @@
|
|
|
18
18
|
* never land in argv/process listings.
|
|
19
19
|
*/
|
|
20
20
|
import { registerWebhooks } from "../channel-ingress.js";
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/** The `Type` values that put an app on `https://<app>.fly.dev`, BY FAMILY — one entry per allocate
|
|
22
|
+
* command, because that is the granularity the answer is acted on at. The rest of flyctl's
|
|
23
|
+
* vocabulary — `private_v6` (Flycast), `egress_v4`, `egress_v6`, `egress_pair` — is internal or
|
|
24
|
+
* outbound. */
|
|
25
|
+
const INGRESS_TYPES = { v4: ["v4", "shared_v4"], v6: ["v6"] };
|
|
26
|
+
/**
|
|
27
|
+
* Which PUBLIC ingress families `fly ips list --json` shows — asked per family, because "has an
|
|
28
|
+
* ingress address" is not the question the two allocate commands answer. An app holding only a `v6`
|
|
29
|
+
* passes the coarse test and still resolves `<app>.fly.dev` to an AAAA record alone, so an IPv4-only
|
|
30
|
+
* webhook sender (Telegram, GitHub) reproduces #425 against it.
|
|
31
|
+
*
|
|
32
|
+
* The list is EVERY assignment the app holds, and a Flycast or egress address carries a non-empty
|
|
33
|
+
* `Address` too — reading one as routable pre-empts flyctl's own first-deploy fallback, which returns
|
|
34
|
+
* early once any assignment exists.
|
|
35
|
+
*
|
|
36
|
+
* Output that is not a JSON array THROWS rather than reading as "no address": the caller turns that
|
|
37
|
+
* into a gate, because a list we cannot read is not a state we can act on.
|
|
38
|
+
*/
|
|
39
|
+
export function ingressAddresses(stdout) {
|
|
40
|
+
const entries = JSON.parse(stdout);
|
|
41
|
+
if (!Array.isArray(entries))
|
|
42
|
+
throw new Error(`expected a JSON array, got ${typeof entries}`);
|
|
43
|
+
const has = (types) => entries.some((entry) => typeof entry?.Address === "string" && entry.Address !== "" && types.includes(entry.Type));
|
|
44
|
+
return { v4: has(INGRESS_TYPES.v4), v6: has(INGRESS_TYPES.v6) };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept
|
|
48
|
+
* both). Exported for the same reason railway's parsers are: what it encodes is an assumption about
|
|
49
|
+
* another tool's output, and the live probe checks that assumption against the real `flyctl`.
|
|
50
|
+
*
|
|
51
|
+
* THROWS on output that is not a JSON array, for the same reason {@link ingressAddresses} does —
|
|
52
|
+
* `false` here means "the host does not have it", and answering that for a list nobody could read
|
|
53
|
+
* creates a SECOND volume, or in teardown destroys nothing at all.
|
|
54
|
+
*/
|
|
55
|
+
export function listHasName(stdout, name) {
|
|
56
|
+
const entries = JSON.parse(stdout);
|
|
57
|
+
if (!Array.isArray(entries))
|
|
58
|
+
throw new Error(`expected a JSON array, got ${typeof entries}`);
|
|
59
|
+
return entries.some((o) => o.Name === name || o.name === name);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A read-only `fly … list --json`, reduced to the question the next step asks of it — or the gate for
|
|
63
|
+
* a list we cannot act on. THE place the three answers stay three: the command failed, the output was
|
|
64
|
+
* unreadable, or the host gave a verdict. Collapsing the middle one into `false` is what shipped #425
|
|
65
|
+
* and what would have skipped a teardown.
|
|
66
|
+
*/
|
|
67
|
+
async function readList(fly, args, read) {
|
|
68
|
+
// The command as RUN, not a restatement of it: a gate that tells the operator to run it themselves
|
|
69
|
+
// has to name one that works from their cwd, and `fly ips list --json` without `-a <app>` does not
|
|
70
|
+
// (fly.toml lives under the agent prefix, so flyctl finds no app).
|
|
71
|
+
const cmd = `fly ${args.join(" ")}`;
|
|
72
|
+
const result = await fly(args, { capture: true });
|
|
73
|
+
if (result.code !== 0)
|
|
74
|
+
return { gate: `\`${cmd}\` failed — see the flyctl output above; fix and re-run` };
|
|
23
75
|
try {
|
|
24
|
-
|
|
25
|
-
return (Array.isArray(arr) &&
|
|
26
|
-
arr.some((o) => o.Name === name || o.name === name));
|
|
76
|
+
return { value: read(result.stdout) };
|
|
27
77
|
}
|
|
28
|
-
catch {
|
|
29
|
-
|
|
78
|
+
catch (error) {
|
|
79
|
+
// The one place a parse failure is allowed to stop being an exception: this IS the boundary that
|
|
80
|
+
// turns it into the operator's gate (printed + non-zero exit), never a default answer.
|
|
81
|
+
return {
|
|
82
|
+
gate: `\`${cmd}\` was unreadable (${error instanceof Error ? error.message : String(error)}) — ` +
|
|
83
|
+
`run it yourself and check the flyctl version; fix and re-run`,
|
|
84
|
+
};
|
|
30
85
|
}
|
|
31
86
|
}
|
|
32
87
|
/**
|
|
@@ -46,12 +101,13 @@ export async function deployFlyRun(plan, fly, log, registrars) {
|
|
|
46
101
|
if (plan.missingSecrets.length > 0) {
|
|
47
102
|
return gate(`no local value for: ${plan.missingSecrets.join(", ")} — set them in .env (or the environment) and re-run`);
|
|
48
103
|
}
|
|
49
|
-
// 3. App — idempotent (create only if absent; a taken global name is a gate).
|
|
50
|
-
// gate: inferring "absent" from
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
104
|
+
// 3. App — idempotent (create only if absent; a taken global name is a gate). An unusable list is its
|
|
105
|
+
// own gate ({@link readList}): inferring "absent" from a query nobody could read would then
|
|
106
|
+
// misreport the create as a name clash.
|
|
107
|
+
const appExists = await readList(fly, ["apps", "list", "--json"], (out) => listHasName(out, plan.appName));
|
|
108
|
+
if ("gate" in appExists)
|
|
109
|
+
return gate(appExists.gate);
|
|
110
|
+
if (appExists.value) {
|
|
55
111
|
log(`app ${plan.appName} exists — skipping create`);
|
|
56
112
|
}
|
|
57
113
|
else {
|
|
@@ -63,10 +119,10 @@ export async function deployFlyRun(plan, fly, log, registrars) {
|
|
|
63
119
|
}
|
|
64
120
|
// 4. Volume — idempotent; region comes from fly.toml (must match the machine's region). A failed list
|
|
65
121
|
// gates for the same reason as the app list above.
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
return gate(
|
|
69
|
-
if (
|
|
122
|
+
const volumeExists = await readList(fly, ["volumes", "list", "-a", plan.appName, "--json"], (out) => listHasName(out, "data"));
|
|
123
|
+
if ("gate" in volumeExists)
|
|
124
|
+
return gate(volumeExists.gate);
|
|
125
|
+
if (volumeExists.value) {
|
|
70
126
|
log(`volume data exists — skipping create`);
|
|
71
127
|
}
|
|
72
128
|
else {
|
|
@@ -76,7 +132,33 @@ export async function deployFlyRun(plan, fly, log, registrars) {
|
|
|
76
132
|
return gate("`fly volumes create` failed — see the flyctl output above");
|
|
77
133
|
}
|
|
78
134
|
}
|
|
79
|
-
// 5.
|
|
135
|
+
// 5. Ingress address — `[http_service]` in fly.toml DECLARES a service; it does not create an address
|
|
136
|
+
// to reach it on. `fly launch` allocates one as part of its flow, and this driver does not use it,
|
|
137
|
+
// so without this step the deploy succeeds, the machine serves, and `https://<app>.fly.dev` has no
|
|
138
|
+
// DNS record at all — reported as a healthy deploy, and handed to the webhook registrars as a URL
|
|
139
|
+
// they then fail to reach.
|
|
140
|
+
const addresses = await readList(fly, ["ips", "list", "-a", plan.appName, "--json"], ingressAddresses);
|
|
141
|
+
if ("gate" in addresses)
|
|
142
|
+
return gate(addresses.gate);
|
|
143
|
+
// Check-then-act PER FAMILY: an app that already holds one must still be given the other, or the
|
|
144
|
+
// gate between the two allocations below heals into a permanent half-state — the re-run the gate
|
|
145
|
+
// asks for sees the v4 it just made, skips, and reports success on an app with no v6 (and, the way
|
|
146
|
+
// that matters, no v4). v4 SHARED and v6 are both free; a dedicated v4 is billed, so it stays an
|
|
147
|
+
// operator decision — `fly ips allocate-v4 -a <app>` after the fact, which this step reads as v4.
|
|
148
|
+
for (const [family, allocate] of [
|
|
149
|
+
["v4", ["ips", "allocate-v4", "--shared", "-a", plan.appName]],
|
|
150
|
+
["v6", ["ips", "allocate-v6", "-a", plan.appName]],
|
|
151
|
+
]) {
|
|
152
|
+
if (addresses.value[family]) {
|
|
153
|
+
log(`public ${family} address exists — skipping allocate`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
log(`allocating a public ${family} address…`);
|
|
157
|
+
if ((await fly([...allocate])).code !== 0) {
|
|
158
|
+
return gate(`\`fly ${allocate.join(" ")}\` failed — see the flyctl output above`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 6. Secrets — staged (no deploy yet; we deploy with fly.toml next). Values over stdin, not argv.
|
|
80
162
|
const keys = Object.keys(plan.secrets);
|
|
81
163
|
if (keys.length > 0) {
|
|
82
164
|
log(`setting ${keys.length} secret(s): ${keys.join(", ")}`);
|
|
@@ -85,7 +167,7 @@ export async function deployFlyRun(plan, fly, log, registrars) {
|
|
|
85
167
|
return gate("`fly secrets import` failed — see the flyctl output above");
|
|
86
168
|
}
|
|
87
169
|
}
|
|
88
|
-
//
|
|
170
|
+
// 7. Deploy — remote builder (no local Docker), one machine. Context + Dockerfile are passed
|
|
89
171
|
// explicitly (the workspace root is the context; the Dockerfile lives under fastagent/).
|
|
90
172
|
log("deploying (remote build)…");
|
|
91
173
|
const deployArgs = [
|
|
@@ -104,7 +186,7 @@ export async function deployFlyRun(plan, fly, log, registrars) {
|
|
|
104
186
|
if ((await fly(deployArgs)).code !== 0) {
|
|
105
187
|
return gate("`fly deploy` failed — see the flyctl output above; fix and re-run");
|
|
106
188
|
}
|
|
107
|
-
//
|
|
189
|
+
// 8. Post-deploy webhook — which channels, in what words, and the gate policy are all the shared
|
|
108
190
|
// kernel's; Fly contributes its deterministic URL and how to retry.
|
|
109
191
|
const registrationGateMsg = await registerWebhooks({
|
|
110
192
|
baseUrl: `https://${plan.appName}.fly.dev`,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** The deploy targets, as a value: the CLI's `<host>` choices and the host-only-flag table's
|
|
2
|
+
* exhaustiveness check both read it. Dependency-free, so `cli/program.ts` can import it at load
|
|
3
|
+
* time without pulling a command module. */
|
|
4
|
+
export declare const DEPLOY_HOSTS: readonly ["docker", "fly", "railway", "agentcore"];
|
|
5
|
+
export type DeployHost = (typeof DEPLOY_HOSTS)[number];
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The deploy targets, as a value: the CLI's `<host>` choices and the host-only-flag table's
|
|
2
|
+
* exhaustiveness check both read it. Dependency-free, so `cli/program.ts` can import it at load
|
|
3
|
+
* time without pulling a command module. */
|
|
4
|
+
export const DEPLOY_HOSTS = ["docker", "fly", "railway", "agentcore"];
|
package/dist/deploy/preflight.js
CHANGED
|
@@ -14,7 +14,7 @@ import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
|
14
14
|
import ignore from "ignore";
|
|
15
15
|
import { classifyBind } from "../bind.js";
|
|
16
16
|
import { resolveAuthPath } from "../engines/pi/config.js";
|
|
17
|
-
import { resolveSecretsDir, resolveStateRoot, exists } from "../paths.js";
|
|
17
|
+
import { resolveSecretsDir, resolveStateRoot, exists, readTextIfExists } from "../paths.js";
|
|
18
18
|
import { inspectChannels } from "../channels/discover.js";
|
|
19
19
|
import { discoverScheduleFiles } from "../schedule/discover.js";
|
|
20
20
|
import { createPiModelRuntime, modelCredentialCarry, probeAuthSource } from "../engines/pi/models.js";
|
|
@@ -86,7 +86,7 @@ export async function preflightDeploy(input) {
|
|
|
86
86
|
`same value to callers: attach --url <public-url> --token …. Unset, the box mints its own per boot — ` +
|
|
87
87
|
`readable only by shelling in (\`docker compose exec\`/\`fly ssh console\`: <stateRoot>/control.json, whose ` +
|
|
88
88
|
`url field is container-loopback) and replaced on every restart. Front the endpoint with real auth for ` +
|
|
89
|
-
`anything wider (design §14)`,
|
|
89
|
+
`anything wider (docs/design/session-control.md §14)`,
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
92
|
// Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
|
|
@@ -299,7 +299,7 @@ export async function preflightDeploy(input) {
|
|
|
299
299
|
// `docker build` (so it is the file that actually decides `deploy docker`). Checking only the root one
|
|
300
300
|
// left the credential gate not covering the path it was written for.
|
|
301
301
|
for (const rel of [".dockerignore", `${agentPrefix}Dockerfile.dockerignore`]) {
|
|
302
|
-
const kept = await
|
|
302
|
+
const kept = await readTextIfExists(join(workspace, rel));
|
|
303
303
|
if (kept === undefined)
|
|
304
304
|
continue;
|
|
305
305
|
// One WE generated is regenerated by this very run under --force, so checking the stale content on
|
|
@@ -50,6 +50,11 @@ export interface RailwayPlan {
|
|
|
50
50
|
* config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
|
|
51
51
|
* convention); two mechanisms, two documented spellings, one fact each. */
|
|
52
52
|
export declare const dockerfilePathVar: (prefix: string) => string;
|
|
53
|
+
/** The name this tool gives BOTH the project and the service, derived from the workspace directory.
|
|
54
|
+
* Railway names are project-scoped (not globally unique like a Fly app), so this only has to survive
|
|
55
|
+
* the command — slug anything that would break `railway add --service <name>`, and name the fallback
|
|
56
|
+
* rather than emitting an empty argument. Shared so the live probe tears down what the CLI creates. */
|
|
57
|
+
export declare function toRailwayName(basename: string): string;
|
|
53
58
|
/** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
|
|
54
59
|
export declare function isGeneratedRailwayJson(content: string): boolean;
|
|
55
60
|
/** Compute the Railway deploy plan from the resolved definition. */
|
|
@@ -8,6 +8,13 @@ const MOUNT = "/data";
|
|
|
8
8
|
* config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
|
|
9
9
|
* convention); two mechanisms, two documented spellings, one fact each. */
|
|
10
10
|
export const dockerfilePathVar = (prefix) => `/${prefix}Dockerfile`;
|
|
11
|
+
/** The name this tool gives BOTH the project and the service, derived from the workspace directory.
|
|
12
|
+
* Railway names are project-scoped (not globally unique like a Fly app), so this only has to survive
|
|
13
|
+
* the command — slug anything that would break `railway add --service <name>`, and name the fallback
|
|
14
|
+
* rather than emitting an empty argument. Shared so the live probe tears down what the CLI creates. */
|
|
15
|
+
export function toRailwayName(basename) {
|
|
16
|
+
return basename.replace(/[^a-zA-Z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
17
|
+
}
|
|
11
18
|
/** railway.json is JSON, so its ownership marker is a KEY rather than a comment line. Railway ignores
|
|
12
19
|
* unknown keys; the predicate below is what lets `--force` reset OUR file and keep a hand-written one. */
|
|
13
20
|
const GENERATED_RAILWAY_KEY = "x-generated-by";
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Secrets go in one-per-`variable set --stdin` (value on stdin, never argv/process listing — Railway has
|
|
23
23
|
* no bulk stdin import like Fly's `secrets import`). Auth needs an ACCOUNT credential (login or
|
|
24
|
-
* `
|
|
24
|
+
* `RAILWAY_API_TOKEN`), not the project-scoped `RAILWAY_TOKEN`: `init` creates a project that a project
|
|
25
|
+
* token can't predate.
|
|
25
26
|
*/
|
|
26
27
|
import { type Registrars } from "../channel-ingress.ts";
|
|
27
28
|
import type { DeclaredChannel } from "../../channels/discover.ts";
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Secrets go in one-per-`variable set --stdin` (value on stdin, never argv/process listing — Railway has
|
|
23
23
|
* no bulk stdin import like Fly's `secrets import`). Auth needs an ACCOUNT credential (login or
|
|
24
|
-
* `
|
|
24
|
+
* `RAILWAY_API_TOKEN`), not the project-scoped `RAILWAY_TOKEN`: `init` creates a project that a project
|
|
25
|
+
* token can't predate.
|
|
25
26
|
*/
|
|
26
27
|
import { registerWebhooks } from "../channel-ingress.js";
|
|
27
28
|
/**
|
|
@@ -100,10 +101,10 @@ export async function deployRailwayRun(plan, railway, log, registrars) {
|
|
|
100
101
|
// and it's ordered before the volume (which has no --service), so the mismatch fails visibly with no
|
|
101
102
|
// side effect. (Pre-checking the name means walking status's nested multi-service shape — not worth it.)
|
|
102
103
|
const svc = ["--service", plan.name];
|
|
103
|
-
// 1. Auth needs an ACCOUNT credential (browser login or
|
|
104
|
-
// predate the project `init` creates. `whoami` succeeds with either.
|
|
104
|
+
// 1. Auth needs an ACCOUNT credential (browser login or RAILWAY_API_TOKEN) — the project-scoped
|
|
105
|
+
// RAILWAY_TOKEN can't predate the project `init` creates. `whoami` succeeds with either.
|
|
105
106
|
if ((await railway(["whoami"], { capture: true })).code !== 0) {
|
|
106
|
-
return gate("not logged in to Railway — run `railway login`, or set
|
|
107
|
+
return gate("not logged in to Railway — run `railway login`, or set RAILWAY_API_TOKEN (an account token), then re-run");
|
|
107
108
|
}
|
|
108
109
|
// 2. Gate missing required secret VALUES before any side effect (no half-created infra).
|
|
109
110
|
if (plan.missingSecrets.length > 0) {
|
package/dist/deploy/secrets.js
CHANGED
|
@@ -106,23 +106,6 @@ export function assembleSecrets(input) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
// Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
|
|
110
|
-
// manually configured long-lived token works, but a partial bundle must gate before the container
|
|
111
|
-
// reaches slackChannel construction.
|
|
112
|
-
if (input.channels.some((channel) => channel.name === "slack")) {
|
|
113
|
-
const rotation = [
|
|
114
|
-
"SLACK_BOT_REFRESH_TOKEN",
|
|
115
|
-
"SLACK_BOT_TOKEN_EXPIRES_AT",
|
|
116
|
-
"SLACK_CLIENT_ID",
|
|
117
|
-
"SLACK_CLIENT_SECRET",
|
|
118
|
-
];
|
|
119
|
-
if (rotation.some((name) => !!input.env[name])) {
|
|
120
|
-
for (const name of rotation) {
|
|
121
|
-
if (!input.env[name] && !missingSecrets.includes(name))
|
|
122
|
-
missingSecrets.push(name);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
109
|
for (const name of input.extraSecrets ?? []) {
|
|
127
110
|
if (name in secrets || missingSecrets.includes(name))
|
|
128
111
|
continue; // already covered by model/channel — no dup
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type
|
|
3
|
-
import { type AgentSessionServices, type CreateAgentSessionServicesOptions, type ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { type AgentSessionServices, type CreateAgentSessionServicesOptions, type ModelRuntime, type SessionManager, createAgentSessionFromServices } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import type { PiAgentSessionFactory } from "./invoke-session.ts";
|
|
5
4
|
import type { PiSessionRecordStore } from "./session-store.ts";
|
|
6
5
|
import { type MountedTool } from "./tool.ts";
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { type AnyModel } from "./models.ts";
|
|
7
|
+
interface PiSessionDefinition {
|
|
8
|
+
systemPrompt?: string;
|
|
9
|
+
skills: Skill[];
|
|
10
|
+
}
|
|
9
11
|
export interface PiAgentSessionFactoryOptions {
|
|
10
12
|
/** Where conversations live. Continuity = same store + same session id. */
|
|
11
13
|
sessions: PiSessionRecordStore;
|
|
@@ -23,16 +25,8 @@ export interface PiAgentSessionFactoryOptions {
|
|
|
23
25
|
}>;
|
|
24
26
|
thinkingLevel?: ThinkingLevel;
|
|
25
27
|
tools?: MountedTool[];
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
skills?: Skill[];
|
|
29
|
-
/** Per-turn source for the prompt+skills PAIR; supersedes the two above. This is what keeps
|
|
30
|
-
* "the directory is the agent, LIVE" true on a shared `services`: the ResourceLoader is built
|
|
31
|
-
* once, but what it serves is re-read here. */
|
|
32
|
-
live?: () => Promise<{
|
|
33
|
-
systemPrompt?: string;
|
|
34
|
-
skills?: Skill[];
|
|
35
|
-
}>;
|
|
28
|
+
/** Read once per binding; prompt and skills come from the same definition read. */
|
|
29
|
+
readDefinition: () => PiSessionDefinition | Promise<PiSessionDefinition>;
|
|
36
30
|
/** The agent's working directory — what fastagent-defined tools see as `cwd`. */
|
|
37
31
|
cwd: string;
|
|
38
32
|
/**
|
|
@@ -66,9 +60,35 @@ export interface PiAgentSessionFactoryOptions {
|
|
|
66
60
|
extensionPaths?: string[];
|
|
67
61
|
/** Built-ins omitted by an explicit lower-level tool list. */
|
|
68
62
|
excludedToolNames?: readonly string[];
|
|
69
|
-
/** Filesystem/process environment: definition loading, and the turn context for tools that read one. */
|
|
70
|
-
env: ExecutionEnv;
|
|
71
63
|
}
|
|
64
|
+
export interface BindPiSessionOptions {
|
|
65
|
+
services: AgentSessionServices;
|
|
66
|
+
sessionManager: SessionManager;
|
|
67
|
+
/** Chat only: pi's runtime hands the resumed/new session its start event. */
|
|
68
|
+
sessionStartEvent?: Parameters<typeof createAgentSessionFromServices>[0]["sessionStartEvent"];
|
|
69
|
+
model: AnyModel;
|
|
70
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
71
|
+
tools: MountedTool[];
|
|
72
|
+
/** The agent's working directory — what fastagent-defined tools see as `cwd`. */
|
|
73
|
+
cwd: string;
|
|
74
|
+
/** Built-ins omitted by an explicit lower-level tool list. */
|
|
75
|
+
excludedToolNames?: readonly string[];
|
|
76
|
+
/** The CALLER's session id — what a tool asking which conversation it is in hears. Defaults to
|
|
77
|
+
* pi's own id, which is right where the caller has none (chat). */
|
|
78
|
+
sessionId?: string;
|
|
79
|
+
/** Record each discovered activation on the session, so the next bind restores it. Off for chat:
|
|
80
|
+
* pi's own session has nowhere to put one, so a resumed chat re-discovers. */
|
|
81
|
+
recordActivations: boolean;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Bind ONE pi session to a record: the definition's tools as pi definitions over one turn context,
|
|
85
|
+
* pi's own tool copies kept off, and deferral applied. The per-invoke factory and the resident chat
|
|
86
|
+
* runtime both bind here — they differ in what they hand in (a shared vs a per-session `services`,
|
|
87
|
+
* record-resolved vs configured settings) and in whether a discovered activation has a record to
|
|
88
|
+
* land in, and in nothing else. Two copies of this drifted once (the tool adapter and the deferral
|
|
89
|
+
* narrowing each existed twice, identical but for those parameters).
|
|
90
|
+
*/
|
|
91
|
+
export declare function bindPiSession(options: BindPiSessionOptions): ReturnType<typeof createAgentSessionFromServices>;
|
|
72
92
|
/**
|
|
73
93
|
* Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
|
|
74
94
|
* carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
|
|
@@ -54,31 +54,102 @@ const warnedDroppedActivations = new Set();
|
|
|
54
54
|
* tool needs the session to reach the turn context. A tool that somehow runs before that binding
|
|
55
55
|
* throws rather than executing outside the turn: a broken lifecycle must not look like a normal
|
|
56
56
|
* out-of-turn call.
|
|
57
|
-
*
|
|
58
|
-
* It carries the whole turn CONTEXT, not just the session: the activation bridge holds the lock that
|
|
59
|
-
* orders concurrent activations, so it has to live as long as the session the activations mutate.
|
|
60
|
-
* Building it here, per call, would hand each parallel tool its own.
|
|
61
57
|
*/
|
|
62
|
-
function toolDefinitions(tools,
|
|
58
|
+
function toolDefinitions(tools, bound, sessionId) {
|
|
63
59
|
return tools.map((tool) => ({
|
|
64
|
-
|
|
65
|
-
label: tool.name,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// without it pi's outer active-set diff double-stamps parallel calls.
|
|
70
|
-
executionMode: tool.executionMode,
|
|
71
|
-
execute: (id, params, signal) => {
|
|
72
|
-
const context = bound.context;
|
|
73
|
-
if (!context)
|
|
60
|
+
...tool,
|
|
61
|
+
label: tool.label || tool.name,
|
|
62
|
+
execute: (id, params, signal, onUpdate, ctx) => {
|
|
63
|
+
const binding = bound.current;
|
|
64
|
+
if (!binding)
|
|
74
65
|
throw new Error("tool executed before its turn context was bound (lifecycle invariant broken)");
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
66
|
+
const { session, context } = binding;
|
|
67
|
+
// Expose the caller's id rather than Pi's filename encoding.
|
|
68
|
+
const sessionManager = Object.create(ctx.sessionManager, {
|
|
69
|
+
getSessionId: { value: () => sessionId },
|
|
70
|
+
getHeader: {
|
|
71
|
+
value: () => {
|
|
72
|
+
const header = ctx.sessionManager.getHeader();
|
|
73
|
+
return header ? { ...header, id: sessionId } : header;
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
// Pi's thinking getter uses a shared runtime; a restored cwd may be a symlink spelling.
|
|
78
|
+
const scoped = Object.create(ctx, {
|
|
79
|
+
sessionManager: { value: sessionManager },
|
|
80
|
+
cwd: { value: context.cwd },
|
|
81
|
+
thinkingLevel: { get: () => session.thinkingLevel },
|
|
82
|
+
});
|
|
83
|
+
return turnContext.run(context, () => tool.execute(id, params, signal, onUpdate, scoped));
|
|
79
84
|
},
|
|
80
85
|
}));
|
|
81
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Bind ONE pi session to a record: the definition's tools as pi definitions over one turn context,
|
|
89
|
+
* pi's own tool copies kept off, and deferral applied. The per-invoke factory and the resident chat
|
|
90
|
+
* runtime both bind here — they differ in what they hand in (a shared vs a per-session `services`,
|
|
91
|
+
* record-resolved vs configured settings) and in whether a discovered activation has a record to
|
|
92
|
+
* land in, and in nothing else. Two copies of this drifted once (the tool adapter and the deferral
|
|
93
|
+
* narrowing each existed twice, identical but for those parameters).
|
|
94
|
+
*/
|
|
95
|
+
export async function bindPiSession(options) {
|
|
96
|
+
const { services, sessionManager, model, thinkingLevel, tools, cwd, recordActivations } = options;
|
|
97
|
+
const excludedToolNames = options.excludedToolNames ?? [];
|
|
98
|
+
const deferred = tools.filter(isDeferredTool).map((t) => t.name);
|
|
99
|
+
const bound = {};
|
|
100
|
+
const sessionId = options.sessionId ?? sessionManager.getSessionId();
|
|
101
|
+
const result = await createAgentSessionFromServices({
|
|
102
|
+
services,
|
|
103
|
+
sessionManager,
|
|
104
|
+
...(options.sessionStartEvent ? { sessionStartEvent: options.sessionStartEvent } : {}),
|
|
105
|
+
model,
|
|
106
|
+
thinkingLevel,
|
|
107
|
+
// pi would otherwise mount its built-ins on top of fastagent's copies, offering duplicate names.
|
|
108
|
+
// Lower-level callers with an explicit list also rely on omitted built-ins staying omitted. And
|
|
109
|
+
// NO `tools` allowlist: it would freeze the set at bind time, while pi lets an extension register
|
|
110
|
+
// from `session_start` — `noTools: "builtin"` gives the guarantee the allowlist was there for.
|
|
111
|
+
noTools: "builtin",
|
|
112
|
+
...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
|
|
113
|
+
customTools: toolDefinitions(tools, bound, sessionId),
|
|
114
|
+
});
|
|
115
|
+
const { session } = result;
|
|
116
|
+
// One context for the whole session: it describes the SESSION, not the call. The activation
|
|
117
|
+
// bridge above all — a tool call has to see what the previous one activated.
|
|
118
|
+
bound.current = {
|
|
119
|
+
session,
|
|
120
|
+
context: {
|
|
121
|
+
cwd,
|
|
122
|
+
sessionManager: agentSessionManager(session, sessionId),
|
|
123
|
+
// Persist each discovery so a served session can restore it on its next turn.
|
|
124
|
+
tools: sessionToolActivation(session, recordActivations
|
|
125
|
+
? (added) => session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added })
|
|
126
|
+
: undefined),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
// Deferral, then restoration: pi starts every mounted tool active, so narrow by SUBTRACTING the
|
|
130
|
+
// deferred names (robust to pi mounting tools of its own, unlike an exact-set replacement), then
|
|
131
|
+
// add back what THIS session has already discovered.
|
|
132
|
+
if (deferred.length > 0) {
|
|
133
|
+
const active = session.getActiveToolNames();
|
|
134
|
+
const mounted = new Set(session.getAllTools().map((tool) => tool.name));
|
|
135
|
+
const recorded = recordActivations ? recordedActivations(session) : [];
|
|
136
|
+
// A recorded name that is no longer mounted is dropped rather than replayed: pi's setter
|
|
137
|
+
// THROWS on an unknown name, so replaying one would brick every future turn of this session.
|
|
138
|
+
const restored = recorded.filter((name) => mounted.has(name));
|
|
139
|
+
const dropped = recorded.filter((name) => !mounted.has(name));
|
|
140
|
+
if (dropped.length > 0) {
|
|
141
|
+
const key = `${sessionId}\u0000${[...new Set(dropped)].sort().join(",")}`;
|
|
142
|
+
const emit = warnedDroppedActivations.has(key) ? log.debug : log.warn;
|
|
143
|
+
warnedDroppedActivations.add(key);
|
|
144
|
+
emit(`[fastagent] session ${sessionId}: dropping recorded activation(s) no longer mounted: ${[...new Set(dropped)].join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
const next = [...new Set([...active.filter((name) => !deferred.includes(name)), ...restored])];
|
|
147
|
+
if (next.length !== active.length || next.some((name) => !active.includes(name))) {
|
|
148
|
+
session.setActiveToolsByName(next);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
82
153
|
/**
|
|
83
154
|
* Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
|
|
84
155
|
* carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
|
|
@@ -121,7 +192,7 @@ export function definitionResourceLoaderOptions(source) {
|
|
|
121
192
|
// and substitutes its own coding-assistant identity, which an L1 agent
|
|
122
193
|
// (`createPiAgent({ model, tools })`) never asked for. pi appends its own working-directory line
|
|
123
194
|
// either way — that is engine behaviour this binding does not fight.
|
|
124
|
-
systemPromptOverride: () => source.systemPrompt()
|
|
195
|
+
systemPromptOverride: () => source.systemPrompt() || " ",
|
|
125
196
|
appendSystemPromptOverride: () => [],
|
|
126
197
|
skillsOverride: (base) => ({
|
|
127
198
|
skills: toPiSkills(source.skills()),
|
|
@@ -131,7 +202,7 @@ export function definitionResourceLoaderOptions(source) {
|
|
|
131
202
|
}
|
|
132
203
|
/** Open-or-create the record, then bind a fresh session to it. One call per invoke. */
|
|
133
204
|
export function piAgentSessionFactory(options) {
|
|
134
|
-
const { sessions, thinkingLevel, cwd
|
|
205
|
+
const { sessions, thinkingLevel, cwd } = options;
|
|
135
206
|
const extensionPaths = options.extensionPaths ?? [];
|
|
136
207
|
const excludedToolNames = options.excludedToolNames ?? [];
|
|
137
208
|
if (extensionPaths.length > 0) {
|
|
@@ -140,10 +211,8 @@ export function piAgentSessionFactory(options) {
|
|
|
140
211
|
"runs concurrent turns for different conversations. See docs/configuration.md#extensions.");
|
|
141
212
|
}
|
|
142
213
|
const tools = options.tools ?? [];
|
|
143
|
-
const deferred = tools.filter(isDeferredTool).map((t) => t.name);
|
|
144
214
|
// What the shared ResourceLoader serves, refreshed per turn before the session is built.
|
|
145
|
-
let
|
|
146
|
-
let skills = options.skills ?? [];
|
|
215
|
+
let definition;
|
|
147
216
|
let services;
|
|
148
217
|
let engine;
|
|
149
218
|
const buildServices = async (modelRuntime) => createAgentSessionServices({
|
|
@@ -155,23 +224,16 @@ export function piAgentSessionFactory(options) {
|
|
|
155
224
|
// CURRENT prompt/skills — serving refreshes both per turn, so a snapshot taken here would
|
|
156
225
|
// serve a stale definition after the first edit.
|
|
157
226
|
resourceLoaderOptions: definitionResourceLoaderOptions({
|
|
158
|
-
systemPrompt: () =>
|
|
159
|
-
skills: () => skills,
|
|
227
|
+
systemPrompt: () => definition.systemPrompt,
|
|
228
|
+
skills: () => definition.skills,
|
|
160
229
|
}),
|
|
161
230
|
});
|
|
162
231
|
return async (sessionId, inherit) => {
|
|
163
|
-
const
|
|
164
|
-
const nextPrompt = fresh
|
|
165
|
-
? fresh.systemPrompt
|
|
166
|
-
: typeof options.systemPrompt === "function"
|
|
167
|
-
? options.systemPrompt()
|
|
168
|
-
: prompt;
|
|
169
|
-
const nextSkills = fresh ? (fresh.skills ?? []) : skills;
|
|
232
|
+
const next = await options.readDefinition();
|
|
170
233
|
engine ??= options.engine();
|
|
171
234
|
const { modelRuntime, model } = await engine;
|
|
172
235
|
if (services === undefined) {
|
|
173
|
-
|
|
174
|
-
skills = nextSkills;
|
|
236
|
+
definition = next;
|
|
175
237
|
services = buildServices(modelRuntime); // assigned before any await: concurrent turns share it
|
|
176
238
|
}
|
|
177
239
|
else {
|
|
@@ -196,10 +258,10 @@ export function piAgentSessionFactory(options) {
|
|
|
196
258
|
// for both. Pinning a snapshot per turn would cost either a loader per turn or a queue in
|
|
197
259
|
// front of every bind, to buy a guarantee nothing asks for.
|
|
198
260
|
const loader = (await services).resourceLoader;
|
|
199
|
-
const definitionChanged = loader.getSystemPrompt() !==
|
|
261
|
+
const definitionChanged = loader.getSystemPrompt() !== (next.systemPrompt || " ") ||
|
|
262
|
+
skillSet(loader.getSkills().skills) !== skillSet(next.skills);
|
|
200
263
|
if (definitionChanged) {
|
|
201
|
-
|
|
202
|
-
skills = nextSkills;
|
|
264
|
+
definition = next;
|
|
203
265
|
await loader.reload();
|
|
204
266
|
}
|
|
205
267
|
}
|
|
@@ -215,63 +277,25 @@ export function piAgentSessionFactory(options) {
|
|
|
215
277
|
model,
|
|
216
278
|
thinkingLevel: thinkingLevel ?? DEFAULT_THINKING_LEVEL,
|
|
217
279
|
});
|
|
218
|
-
const
|
|
219
|
-
const { session } = await createAgentSessionFromServices({
|
|
280
|
+
const { session } = await bindPiSession({
|
|
220
281
|
services: await services,
|
|
221
282
|
sessionManager,
|
|
222
283
|
model: settings.model,
|
|
223
284
|
thinkingLevel: settings.thinkingLevel,
|
|
224
|
-
|
|
225
|
-
// Lower-level callers with an explicit list also rely on omitted built-ins staying omitted.
|
|
226
|
-
noTools: "builtin",
|
|
227
|
-
...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
|
|
228
|
-
customTools: toolDefinitions(tools, env, bound),
|
|
229
|
-
});
|
|
230
|
-
// One context for the whole session: it describes the SESSION, not the call. The activation
|
|
231
|
-
// bridge above all — a tool call has to see what the previous one activated.
|
|
232
|
-
bound.context = {
|
|
285
|
+
tools,
|
|
233
286
|
cwd,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
};
|
|
239
|
-
// An extension handler that throws is otherwise dropped: pi fans errors out to registered
|
|
240
|
-
// listeners and has none by default, which on a server means a broken extension looks like an
|
|
241
|
-
// extension that simply did nothing.
|
|
242
|
-
//
|
|
243
|
-
// Deferral, then restoration: pi starts every mounted tool active, so narrow by SUBTRACTING the
|
|
244
|
-
// deferred names (robust to pi mounting tools of its own, unlike an exact-set replacement), then
|
|
245
|
-
// add back what THIS session has already discovered.
|
|
246
|
-
if (deferred.length > 0) {
|
|
247
|
-
const active = session.getActiveToolNames();
|
|
248
|
-
const mounted = new Set(session.getAllTools().map((tool) => tool.name));
|
|
249
|
-
const recorded = recordedActivations(session);
|
|
250
|
-
// A recorded name that is no longer mounted is dropped rather than replayed: pi's setter
|
|
251
|
-
// THROWS on an unknown name, so replaying one would brick every future turn of this session.
|
|
252
|
-
const restored = recorded.filter((name) => mounted.has(name));
|
|
253
|
-
const dropped = recorded.filter((name) => !mounted.has(name));
|
|
254
|
-
if (dropped.length > 0) {
|
|
255
|
-
const key = `${sessionId}\u0000${[...new Set(dropped)].sort().join(",")}`;
|
|
256
|
-
const emit = warnedDroppedActivations.has(key) ? log.debug : log.warn;
|
|
257
|
-
warnedDroppedActivations.add(key);
|
|
258
|
-
emit(`[fastagent] session ${sessionId}: dropping recorded activation(s) no longer mounted: ${[...new Set(dropped)].join(", ")}`);
|
|
259
|
-
}
|
|
260
|
-
const next = [...new Set([...active.filter((name) => !deferred.includes(name)), ...restored])];
|
|
261
|
-
if (next.length !== active.length || next.some((name) => !active.includes(name))) {
|
|
262
|
-
session.setActiveToolsByName(next);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
287
|
+
excludedToolNames,
|
|
288
|
+
sessionId,
|
|
289
|
+
recordActivations: true,
|
|
290
|
+
});
|
|
265
291
|
return session;
|
|
266
292
|
};
|
|
267
293
|
}
|
|
268
294
|
/** What a reload has to notice: the declared set, not the files behind it. */
|
|
269
295
|
function skillSet(skills) {
|
|
270
|
-
return skills
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function loadedSkillSet(skills) {
|
|
274
|
-
return skills.map((s) => `${s.name}\u0000${s.filePath ?? ""}\u0000${s.description}`).join("\u0001");
|
|
296
|
+
return skills
|
|
297
|
+
.map((s) => `${s.name}\u0000${s.filePath ?? ""}\u0000${s.description}\u0000${s.disableModelInvocation ?? false}`)
|
|
298
|
+
.join("\u0001");
|
|
275
299
|
}
|
|
276
300
|
/** fastagent's Skill (content inline) as pi's (read from filePath at invocation time). */
|
|
277
301
|
function toPiSkills(skills) {
|