@fastagent-sh/fastagent 0.13.0 → 0.14.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/README.md +2 -2
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +4 -2
- package/dist/channels/feishu/crypto.js +4 -2
- package/dist/channels/feishu/feishu-api.d.ts +4 -4
- package/dist/channels/feishu/feishu-api.js +2 -2
- package/dist/channels/feishu/feishu.d.ts +11 -1
- package/dist/channels/feishu/feishu.js +143 -30
- package/dist/channels/feishu/invoke-turn.d.ts +19 -13
- package/dist/channels/feishu/invoke-turn.js +70 -19
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +20 -102
- package/dist/channels/feishu/parse.js +35 -145
- package/dist/channels/feishu/preview.js +2 -2
- package/dist/channels/feishu/scaffold/channel.ts +10 -3
- package/dist/channels/feishu/seen.js +12 -12
- package/dist/channels/lark/scaffold/channel.ts +10 -3
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli.js +8 -1329
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +77 -4
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -5
- package/dist/engines/pi/create.js +44 -9
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.js +3 -1
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/package.json +7 -4
package/dist/engines/pi/auth.js
CHANGED
|
@@ -12,22 +12,24 @@
|
|
|
12
12
|
* token and persisting the new one elsewhere leaves global stale for every other consumer.
|
|
13
13
|
*
|
|
14
14
|
* Sharing is still SAFE the right way: point everything at ONE file (`FASTAGENT_AUTH_PATH` → the
|
|
15
|
-
* global path). One file means one refresh lifecycle under
|
|
16
|
-
*
|
|
15
|
+
* global path). One file means one refresh lifecycle under the store's cross-process write lock
|
|
16
|
+
* (refresh re-reads the latest token under the lock), the documented same-machine pattern.
|
|
17
17
|
* fastagent's store stays SEPARATE from the pi CLI's `~/.pi/agent/auth.json` for the same single-
|
|
18
18
|
* lifecycle reason: two uncoordinated files over one grant would each rotate and break the other.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* path
|
|
20
|
+
* Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
|
|
21
|
+
* before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
|
|
22
|
+
* custom pi-ai `CredentialStore`, which this file is). The lock guards the WRITE path only. `read`
|
|
23
|
+
* is pi-ai's per-request hot path, so it stays UNLOCKED; the in-place locked write opens only a
|
|
24
|
+
* sub-millisecond torn-read window, which `read` absorbs by re-reading. The write path refuses to
|
|
25
|
+
* overwrite a corrupt file (never clobbering other providers' credentials).
|
|
24
26
|
*/
|
|
25
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
28
|
import { homedir } from "node:os";
|
|
27
|
-
import { join } from "node:path";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
28
30
|
import { log } from "../../log.js";
|
|
29
31
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
30
|
-
import
|
|
32
|
+
import lockfile from "proper-lockfile";
|
|
31
33
|
/**
|
|
32
34
|
* The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
|
|
33
35
|
* `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
|
|
@@ -42,57 +44,169 @@ function pick(creds, providerId) {
|
|
|
42
44
|
const cred = creds[providerId];
|
|
43
45
|
return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
|
|
44
46
|
}
|
|
47
|
+
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
49
|
+
* Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
|
|
50
|
+
* 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
|
|
51
|
+
* Ensures the file exists first (0700 dir, 0600 file, EXCLUSIVE create: a concurrent first write
|
|
52
|
+
* must never be clobbered by the init) because `proper-lockfile` locks an existing path. A
|
|
53
|
+
* compromised lock aborts before the write rather than clobbering a concurrent writer, and a
|
|
54
|
+
* failed unlock after a successful operation rejects instead of leaving a stale lock silently.
|
|
48
55
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
async function withLockedAuthFile(authPath, fn) {
|
|
57
|
+
const dir = dirname(authPath);
|
|
58
|
+
if (!existsSync(dir))
|
|
59
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
if (!existsSync(authPath)) {
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
|
|
63
|
+
chmodSync(authPath, 0o600);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// EEXIST: another process created the file between the existence check and this exclusive
|
|
67
|
+
// create; its content (possibly already-written credentials) must not be clobbered.
|
|
68
|
+
if (error.code !== "EEXIST")
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let compromised;
|
|
73
|
+
const throwIfCompromised = () => {
|
|
74
|
+
if (compromised)
|
|
75
|
+
throw compromised;
|
|
76
|
+
};
|
|
77
|
+
const release = await lockfile.lock(authPath, {
|
|
78
|
+
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
|
|
79
|
+
stale: 30_000,
|
|
80
|
+
onCompromised: (error) => {
|
|
81
|
+
compromised = error;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
throwIfCompromised();
|
|
87
|
+
const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
|
|
88
|
+
const out = await fn(current);
|
|
89
|
+
throwIfCompromised();
|
|
90
|
+
if (out.next !== undefined) {
|
|
91
|
+
writeFileSync(authPath, out.next, AUTH_FILE_WRITE_OPTIONS);
|
|
92
|
+
chmodSync(authPath, 0o600);
|
|
93
|
+
}
|
|
94
|
+
throwIfCompromised();
|
|
95
|
+
result = out.result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// The primary failure stays the signal; unlock noise must not mask it.
|
|
99
|
+
try {
|
|
100
|
+
await release();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
// Success path: a failed release is a real cleanup failure (the leftover auth.json.lock stalls
|
|
108
|
+
// the next writer for the staleness window with zero diagnostics), so it surfaces instead of
|
|
109
|
+
// resolving a silently degraded operation. A compromise detected after the last in-band check
|
|
110
|
+
// surfaces here too.
|
|
111
|
+
try {
|
|
112
|
+
await release();
|
|
113
|
+
}
|
|
114
|
+
catch (releaseError) {
|
|
115
|
+
if (compromised === undefined)
|
|
116
|
+
throw releaseError;
|
|
117
|
+
}
|
|
118
|
+
throwIfCompromised();
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Decode the credentials JSON, shared by the read and write paths. The root must be a plain
|
|
123
|
+
* non-null, non-array object: `[]`, `null`, and scalar roots pass JSON.parse but break the record
|
|
124
|
+
* semantics (an array root even swallows writes, since JSON.stringify drops string keys on arrays).
|
|
125
|
+
* Structurally invalid = corrupt, exactly like unparsable text.
|
|
126
|
+
*/
|
|
127
|
+
function decodeCreds(raw) {
|
|
128
|
+
let parsed;
|
|
52
129
|
try {
|
|
53
|
-
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
54
131
|
}
|
|
55
132
|
catch {
|
|
56
|
-
|
|
133
|
+
return undefined;
|
|
57
134
|
}
|
|
135
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
136
|
+
return undefined;
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Tolerant UNLOCKED read of the whole credentials file, shared by `read` and `list`. The only race
|
|
141
|
+
* is a sub-millisecond in-place write during an OAuth rotation, which can yield an empty/partial
|
|
142
|
+
* file; re-read a few times before concluding it is corrupt. A missing file reads as undefined
|
|
143
|
+
* silently (normal not-configured); a valid file returns immediately, so the common case costs one
|
|
144
|
+
* read.
|
|
145
|
+
*/
|
|
146
|
+
async function readCreds(authPath, warn) {
|
|
147
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = readFileSync(authPath, "utf8");
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code === "ENOENT")
|
|
154
|
+
return undefined; // missing/deleted
|
|
155
|
+
warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (raw !== "") {
|
|
159
|
+
const creds = decodeCreds(raw);
|
|
160
|
+
if (creds !== undefined)
|
|
161
|
+
return creds;
|
|
162
|
+
// A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
|
|
163
|
+
// root lands here too and is reported as corrupt below.
|
|
164
|
+
}
|
|
165
|
+
if (attempt < 2)
|
|
166
|
+
await sleep(2);
|
|
167
|
+
}
|
|
168
|
+
warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parse the credentials JSON for a WRITE: a corrupt file must THROW, because serializing `{}` over
|
|
173
|
+
* it would wipe every other provider's credentials. The throw aborts the locked write, leaving the
|
|
174
|
+
* file intact.
|
|
175
|
+
*/
|
|
176
|
+
function parseForWrite(raw, where) {
|
|
177
|
+
if (!raw)
|
|
178
|
+
return {};
|
|
179
|
+
const creds = decodeCreds(raw);
|
|
180
|
+
if (creds === undefined) {
|
|
181
|
+
throw new Error(`refusing to overwrite corrupt auth file ${where}: fix or remove it`);
|
|
182
|
+
}
|
|
183
|
+
return creds;
|
|
58
184
|
}
|
|
59
185
|
/** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
|
|
60
186
|
* the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
|
|
61
187
|
export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options = {}) {
|
|
62
188
|
const warn = options.warn ?? ((message) => log.warn(message));
|
|
63
|
-
const backend = new FileAuthStorageBackend(authPath);
|
|
64
189
|
return {
|
|
65
190
|
async read(providerId) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (raw !== "") {
|
|
81
|
-
try {
|
|
82
|
-
return pick(JSON.parse(raw), providerId);
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// A partial read mid-write parses as garbage — fall through and retry.
|
|
86
|
-
}
|
|
191
|
+
const creds = await readCreds(authPath, warn);
|
|
192
|
+
return creds ? pick(creds, providerId) : undefined;
|
|
193
|
+
},
|
|
194
|
+
async list() {
|
|
195
|
+
// Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
|
|
196
|
+
// with the same validation as `read`, so both surfaces agree on what "configured" means.
|
|
197
|
+
const creds = await readCreds(authPath, warn);
|
|
198
|
+
if (!creds)
|
|
199
|
+
return [];
|
|
200
|
+
const infos = [];
|
|
201
|
+
for (const [providerId, cred] of Object.entries(creds)) {
|
|
202
|
+
if (cred && (cred.type === "oauth" || cred.type === "api_key")) {
|
|
203
|
+
infos.push({ providerId, type: cred.type });
|
|
87
204
|
}
|
|
88
|
-
if (attempt < 2)
|
|
89
|
-
await sleep(2);
|
|
90
205
|
}
|
|
91
|
-
|
|
92
|
-
return undefined;
|
|
206
|
+
return infos;
|
|
93
207
|
},
|
|
94
208
|
modify(providerId, fn) {
|
|
95
|
-
return
|
|
209
|
+
return withLockedAuthFile(authPath, async (current) => {
|
|
96
210
|
const creds = parseForWrite(current, authPath); // corrupt → throw → no clobber
|
|
97
211
|
const next = await fn(pick(creds, providerId));
|
|
98
212
|
if (next === undefined)
|
|
@@ -102,11 +216,11 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
|
|
|
102
216
|
});
|
|
103
217
|
},
|
|
104
218
|
async delete(providerId) {
|
|
105
|
-
// No-op when nothing is stored: do NOT take the lock (which would create the file
|
|
106
|
-
//
|
|
219
|
+
// No-op when nothing is stored: do NOT take the lock (which would create the file) on a
|
|
220
|
+
// machine that never stored this provider.
|
|
107
221
|
if (!existsSync(authPath))
|
|
108
222
|
return;
|
|
109
|
-
await
|
|
223
|
+
await withLockedAuthFile(authPath, async (current) => {
|
|
110
224
|
const creds = parseForWrite(current, authPath);
|
|
111
225
|
if (!(providerId in creds))
|
|
112
226
|
return { result: undefined }; // absent: no write
|
package/dist/engines/pi/chat.js
CHANGED
|
@@ -28,7 +28,9 @@ import { loadConfig, resolveAgentDir, resolveModel, resolveModelSpec } from "./c
|
|
|
28
28
|
import { assembleSystemPrompt, piBasePrompt, piDefaultTools, resolveTools } from "./create.js";
|
|
29
29
|
import { createPiModels } from "./models.js";
|
|
30
30
|
import { canonicalPath, loadAgentDefinition } from "./definition.js";
|
|
31
|
-
import { loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
31
|
+
import { isDeferredTool, loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
32
|
+
import { withSearchTool } from "./search-tools.js";
|
|
33
|
+
import { additiveActivation, turnContext } from "./tool-context.js";
|
|
32
34
|
import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
|
|
33
35
|
/**
|
|
34
36
|
* Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
|
|
@@ -38,6 +40,34 @@ import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollision
|
|
|
38
40
|
export async function buildChatRuntime(dir, options = {},
|
|
39
41
|
/** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
|
|
40
42
|
sessionManager) {
|
|
43
|
+
/** The turn's {@link ToolActivation} over pi's AgentSession — the chat counterpart of invoke.ts's
|
|
44
|
+
* harness bridge, so the SAME builtin search_tools serves both paths. Additive; unknown names
|
|
45
|
+
* filtered (`setActiveToolsByName` is authoritative on the session and rebuilds its prompt — our
|
|
46
|
+
* static override keeps the prompt identical to serving). */
|
|
47
|
+
function chatToolActivation(session) {
|
|
48
|
+
// Same serialization as invoke.ts's bridge (there per turn; here per session — chat turns are
|
|
49
|
+
// interactive, so per-session is equivalent): the read-modify-write below is only race-free while
|
|
50
|
+
// nothing awaits between read and write, and pi's session setters happening to be synchronous today
|
|
51
|
+
// is not a contract worth betting parallel tool batches on. Built ONCE per session (createRuntime),
|
|
52
|
+
// so parallel calls actually share the chain.
|
|
53
|
+
let chain = Promise.resolve([]);
|
|
54
|
+
return {
|
|
55
|
+
active: () => session.getActiveToolNames(),
|
|
56
|
+
registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
|
|
57
|
+
activate(names) {
|
|
58
|
+
const run = async () => {
|
|
59
|
+
const current = session.getActiveToolNames();
|
|
60
|
+
const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
|
|
61
|
+
if (added.length > 0)
|
|
62
|
+
session.setActiveToolsByName([...current, ...added]);
|
|
63
|
+
return added;
|
|
64
|
+
};
|
|
65
|
+
const result = chain.then(run, run); // run after the predecessor settles, success or failure
|
|
66
|
+
chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
|
|
67
|
+
return result;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
41
71
|
async function resolveAssembly(cwd) {
|
|
42
72
|
const { config } = await loadConfig(cwd);
|
|
43
73
|
const modelSpec = resolveModelSpec(options.model, config);
|
|
@@ -55,18 +85,39 @@ sessionManager) {
|
|
|
55
85
|
// Same tool resolution as the dev opener, then split: defaults go to pi by NAME (rebuilt cwd-bound
|
|
56
86
|
// for rich rendering); customs go through pi's `customTools` path so they survive /new, /resume, fork.
|
|
57
87
|
const discovered = await loadTools(agentDir);
|
|
58
|
-
const
|
|
88
|
+
const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
|
|
89
|
+
// Chat EMULATES deferral, like serving (what you iterate is what you serve): the builtin loader
|
|
90
|
+
// mounts when a deferred tool exists, the initial active set excludes deferred tools (applied on
|
|
91
|
+
// the session in createRuntime below — pi's TUI session starts all-active), and the activation
|
|
92
|
+
// bridge below rides the same turn context the serving path uses, so the SAME search_tools works
|
|
93
|
+
// against pi's AgentSession instead of fastagent's harness.
|
|
94
|
+
const tools = withSearchTool(merged.tools);
|
|
95
|
+
const crossCollisions = merged.collisions;
|
|
59
96
|
reportToolCollisions([...discovered.collisions, ...crossCollisions]);
|
|
60
97
|
reportModuleLoadFailures(discovered.failures);
|
|
61
98
|
const defaultNames = piDefaultTools(cwd).map((t) => t.name);
|
|
62
99
|
const customTools = tools.filter((t) => !defaultNames.includes(t.name));
|
|
63
|
-
// Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
|
|
100
|
+
// Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
|
|
101
|
+
// it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
|
|
102
|
+
// assembly is memoized across /new//resume/fork rebuilds while the session changes, so the bridge
|
|
103
|
+
// resolves through sessionRef at call time, exactly like the serving path resolves its harness.
|
|
64
104
|
const customToolDefs = customTools.map((t) => ({
|
|
65
105
|
name: t.name,
|
|
66
106
|
label: t.name,
|
|
67
107
|
description: t.description ?? "",
|
|
68
108
|
parameters: t.parameters,
|
|
69
|
-
|
|
109
|
+
// Propagate the execution mode — an activating tool (the builtin loader) declares "sequential"
|
|
110
|
+
// so pi serializes its batch; without this, pi's outer active-set diff double-stamps parallels.
|
|
111
|
+
executionMode: t.executionMode,
|
|
112
|
+
execute: (id, params, signal) => {
|
|
113
|
+
const bound = sessionRef.current;
|
|
114
|
+
// Unreachable by construction (createRuntime sets sessionRef before any turn can run a tool).
|
|
115
|
+
// Throw rather than silently run outside the turn context — that would disguise a broken
|
|
116
|
+
// session-lifecycle invariant as a normal out-of-turn call (fail visibly).
|
|
117
|
+
if (!bound)
|
|
118
|
+
throw new Error("chat tool executed before its session was built (lifecycle invariant broken)");
|
|
119
|
+
return turnContext.run({ session: bound.session.sessionId, tools: bound.activation }, () => t.execute(id, params, signal));
|
|
120
|
+
},
|
|
70
121
|
}));
|
|
71
122
|
// base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
|
|
72
123
|
// them here would duplicate them).
|
|
@@ -82,6 +133,13 @@ sessionManager) {
|
|
|
82
133
|
// edits. And keep it workspace-scoped — `.env` is process-global, so a switch to another cwd would
|
|
83
134
|
// leak env or require mutating global env at runtime.
|
|
84
135
|
const rootCwd = canonicalPath(dir);
|
|
136
|
+
// The CURRENT pi session + its activation bridge, BOUND TOGETHER — rebuilt on /new//resume/fork
|
|
137
|
+
// while the memoized assembly (and its tool execute closures) stays. The bridge must share the
|
|
138
|
+
// session's lifetime, NOT be rebuilt per tool call (a per-call chain serializes nothing). Note on
|
|
139
|
+
// parallel batches: pi wraps SDK customTools in its own before/after active-set diff, so an
|
|
140
|
+
// activating tool must carry `executionMode: "sequential"` (the builtin loader does) — pi then runs
|
|
141
|
+
// the whole batch serially and the outer diff sees correct snapshots.
|
|
142
|
+
const sessionRef = {};
|
|
85
143
|
let assembly;
|
|
86
144
|
const assemblyFor = (cwd) => {
|
|
87
145
|
// Canonical paths: pi's process.cwd() fallback is a realpath, so a symlinked workspace would
|
|
@@ -135,6 +193,21 @@ sessionManager) {
|
|
|
135
193
|
tools: [...defaultNames, ...customTools.map((t) => t.name)],
|
|
136
194
|
customTools: customToolDefs,
|
|
137
195
|
});
|
|
196
|
+
sessionRef.current = { session: result.session, activation: chatToolActivation(result.session) };
|
|
197
|
+
// Deferral emulation: pi's TUI session starts with everything active — narrow it by SUBTRACTING
|
|
198
|
+
// the deferred names from whatever is active (robust to pi mounting tools of its own; an
|
|
199
|
+
// exact-set-equality gate would silently stop narrowing the day pi adds one). Applied on EVERY
|
|
200
|
+
// build including /resume: pi's chat session does not record activations (its SessionContext has
|
|
201
|
+
// no activeToolNames), so "restore prior activations" is not implementable here — deferral stays
|
|
202
|
+
// consistently ON and a resumed conversation re-discovers via search_tools (documented divergence
|
|
203
|
+
// from serving, where activations persist in the session).
|
|
204
|
+
const deferredNames = customTools.filter(isDeferredTool).map((t) => t.name);
|
|
205
|
+
if (deferredNames.length > 0) {
|
|
206
|
+
const active = result.session.getActiveToolNames();
|
|
207
|
+
if (deferredNames.some((n) => active.includes(n))) {
|
|
208
|
+
result.session.setActiveToolsByName(active.filter((n) => !deferredNames.includes(n)));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
138
211
|
return { ...result, services, diagnostics: services.diagnostics };
|
|
139
212
|
};
|
|
140
213
|
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { FastagentTool } from "./tool.ts";
|
|
2
3
|
import type { Models } from "@earendil-works/pi-ai";
|
|
3
4
|
import type { AnyModel } from "./harness.ts";
|
|
5
|
+
/** pi's thinking levels (types.d.ts `ThinkingLevel`), as a runtime list for config validation — pi
|
|
6
|
+
* exports only the type. A level the selected model does not support is clamped by pi per model. */
|
|
7
|
+
export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
4
8
|
export interface FastagentConfig {
|
|
5
9
|
/** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
|
|
6
10
|
model?: string;
|
|
11
|
+
/** Reasoning effort for the model, pi's scale ("off" | "minimal" | "low" | "medium" | "high" |
|
|
12
|
+
* "xhigh" | "max"). Unset = pi's default. Authors tune thinking in the pi TUI while vibing — this
|
|
13
|
+
* is the serving-side counterpart (fidelity). Levels a model doesn't support are clamped by pi. */
|
|
14
|
+
thinkingLevel?: ThinkingLevel;
|
|
7
15
|
/**
|
|
8
16
|
* The agent-definition subdirectory (persona.md, skills/, tools/, channels/), relative to the config
|
|
9
17
|
* file's directory. Default: the config directory itself (flat — today's behaviour). Point it at a
|
|
@@ -12,8 +20,9 @@ export interface FastagentConfig {
|
|
|
12
20
|
* subdir and does not collide with the host's `tools/`/`src/` (core.md scenario grid).
|
|
13
21
|
*/
|
|
14
22
|
agentDir?: string;
|
|
15
|
-
/** Extra custom tools, appended after pi defaults — never replaces them.
|
|
16
|
-
|
|
23
|
+
/** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
|
|
24
|
+
* plus the optional `deferred` marker (see defineTool). */
|
|
25
|
+
tools?: FastagentTool[];
|
|
17
26
|
http?: {
|
|
18
27
|
port?: number;
|
|
19
28
|
};
|
|
@@ -19,6 +19,17 @@ import { homedir } from "node:os";
|
|
|
19
19
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
import { moduleLoadHint } from "../../loader.js";
|
|
22
|
+
/** pi's thinking levels (types.d.ts `ThinkingLevel`), as a runtime list for config validation — pi
|
|
23
|
+
* exports only the type. A level the selected model does not support is clamped by pi per model. */
|
|
24
|
+
export const THINKING_LEVELS = [
|
|
25
|
+
"off",
|
|
26
|
+
"minimal",
|
|
27
|
+
"low",
|
|
28
|
+
"medium",
|
|
29
|
+
"high",
|
|
30
|
+
"xhigh",
|
|
31
|
+
"max",
|
|
32
|
+
];
|
|
22
33
|
/** Identity function for typing and IDE completion (vite/next-style). */
|
|
23
34
|
export function defineConfig(config) {
|
|
24
35
|
return config;
|
|
@@ -70,17 +81,21 @@ export async function loadConfig(dir) {
|
|
|
70
81
|
// (`modle:`) must not silently degrade to zero-config.
|
|
71
82
|
for (const key of Object.keys(c)) {
|
|
72
83
|
if (key !== "model" &&
|
|
84
|
+
key !== "thinkingLevel" &&
|
|
73
85
|
key !== "agentDir" &&
|
|
74
86
|
key !== "tools" &&
|
|
75
87
|
key !== "http" &&
|
|
76
88
|
key !== "deploy" &&
|
|
77
89
|
key !== "selfSchedule") {
|
|
78
|
-
throw new Error(`${path}: unknown key "${key}" (valid keys: model, agentDir, tools, http, deploy, selfSchedule)`);
|
|
90
|
+
throw new Error(`${path}: unknown key "${key}" (valid keys: model, thinkingLevel, agentDir, tools, http, deploy, selfSchedule)`);
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
if (c.model !== undefined && typeof c.model !== "string") {
|
|
82
94
|
throw new Error(`${path}: "model" must be a "provider/modelId" string`);
|
|
83
95
|
}
|
|
96
|
+
if (c.thinkingLevel !== undefined && !THINKING_LEVELS.includes(c.thinkingLevel)) {
|
|
97
|
+
throw new Error(`${path}: "thinkingLevel" must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
98
|
+
}
|
|
84
99
|
if (c.agentDir !== undefined && typeof c.agentDir !== "string") {
|
|
85
100
|
throw new Error(`${path}: "agentDir" must be a string (a subdirectory relative to the config file)`);
|
|
86
101
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { AgentTool, ExecutionEnv, Skill } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import type { AgentTool, ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { 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
6
|
import { type PiSessionStore } from "./sessions.ts";
|
|
7
7
|
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
8
|
-
import { type ToolCollision } from "./tool.ts";
|
|
8
|
+
import { type FastagentTool, type ToolCollision } from "./tool.ts";
|
|
9
9
|
import { type Lease } from "./invoke.ts";
|
|
10
10
|
/** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
|
|
11
11
|
export declare function piDefaultTools(cwd: string): AgentTool[];
|
|
@@ -19,6 +19,9 @@ export declare function resolveTools(config: FastagentConfig, cwd: string): Agen
|
|
|
19
19
|
export declare function resolveWorkspaceTools(config: FastagentConfig, agentDir: string, cwd?: string): Promise<{
|
|
20
20
|
tools: AgentTool[];
|
|
21
21
|
toolNames: string[];
|
|
22
|
+
/** Tools registered but not initially active (defineTool `deferred: true`) — discovered/activated
|
|
23
|
+
* via the built-in `search_tools` loader. Surfaced so the operator can see deferral took effect. */
|
|
24
|
+
deferredToolNames: string[];
|
|
22
25
|
toolCollisions: ToolCollision[];
|
|
23
26
|
toolFailures: ModuleLoadFailure[];
|
|
24
27
|
}>;
|
|
@@ -53,13 +56,16 @@ export declare function assembleSystemPrompt(options: AssembleSystemPromptOption
|
|
|
53
56
|
export interface CreatePiAgentOptions {
|
|
54
57
|
/** Model spec "provider/modelId" (e.g. "openai-codex/gpt-5.5"), resolved against {@link models}. */
|
|
55
58
|
model: string;
|
|
59
|
+
/** Reasoning effort (pi's scale). Unset = pi's default; unsupported levels are clamped per model. */
|
|
60
|
+
thinkingLevel?: ThinkingLevel;
|
|
56
61
|
/**
|
|
57
62
|
* The system prompt itself — verbatim, no engine base and no wrapping (unlike the directory path,
|
|
58
63
|
* which assembles the engine base + AGENTS.md as segment ② + persona.md as segment ①). A plain string
|
|
59
64
|
* or a factory re-evaluated per invoke. When {@link skills} are mounted their listing is appended.
|
|
60
65
|
*/
|
|
61
66
|
instructions?: string | (() => string);
|
|
62
|
-
|
|
67
|
+
/** `FastagentTool` = AgentTool plus the optional `deferred` marker (see {@link DefineToolOptions}). */
|
|
68
|
+
tools?: FastagentTool[];
|
|
63
69
|
skills?: Skill[];
|
|
64
70
|
/**
|
|
65
71
|
* Extra providers registered on top of the built-ins — your own gateway / self-hosted endpoint /
|
|
@@ -90,11 +96,14 @@ export declare function createPiAgent(options: CreatePiAgentOptions): Agent;
|
|
|
90
96
|
export interface CreatePiAgentFromDefinitionOptions {
|
|
91
97
|
/** Model spec "provider/modelId", resolved against {@link models}. */
|
|
92
98
|
model: string;
|
|
99
|
+
/** Reasoning effort (pi's scale). Unset = pi's default; unsupported levels are clamped per model. */
|
|
100
|
+
thinkingLevel?: ThinkingLevel;
|
|
93
101
|
/** Override the engine base prompt (segment ①). Defaults to piBasePrompt({ tools, persona }) using the
|
|
94
102
|
* live-read persona.md; pass base to fully opt out of persona.md. */
|
|
95
103
|
base?: string;
|
|
96
|
-
/** Override tools. Defaults to piDefaultTools (lock down with a custom list).
|
|
97
|
-
|
|
104
|
+
/** Override tools. Defaults to piDefaultTools (lock down with a custom list). `FastagentTool` =
|
|
105
|
+
* AgentTool plus the optional `deferred` marker. */
|
|
106
|
+
tools?: FastagentTool[];
|
|
98
107
|
/**
|
|
99
108
|
* The agent's working directory: where the default tools operate AND whose ancestors are walked for
|
|
100
109
|
* ② project context (AGENTS.md). Defaults to `dir` (flat: the definition dir is also the run root).
|
|
@@ -19,7 +19,8 @@ import { piHarnessFactory } from "./harness.js";
|
|
|
19
19
|
import { createPiModels } from "./models.js";
|
|
20
20
|
import { reportDefinitionWarnings } from "./report.js";
|
|
21
21
|
import { inMemorySessionStore } from "./sessions.js";
|
|
22
|
-
import { loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
22
|
+
import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
|
|
23
|
+
import { withSearchTool } from "./search-tools.js";
|
|
23
24
|
import { createPiAgentFromHarness } from "./invoke.js";
|
|
24
25
|
// ── §1 tools ─────────────────────────────────────────────────────────────────
|
|
25
26
|
//
|
|
@@ -44,11 +45,30 @@ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
|
|
|
44
45
|
// Default coding tools (read/bash/edit/write) are rooted at `cwd` (the run root the agent operates on);
|
|
45
46
|
// discovered `tools/` come from `agentDir` (the agent's own surface). They coincide in the flat case.
|
|
46
47
|
const discovered = await loadTools(agentDir);
|
|
47
|
-
const
|
|
48
|
-
|
|
48
|
+
const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
|
|
49
|
+
// The built-in `search_tools` loader mounts here — the one place the workspace's full tool set is
|
|
50
|
+
// computed — so `dev`/`start`/`info`/`fastagent tool` all see the same surface (idempotent; a
|
|
51
|
+
// workspace-defined search_tools wins).
|
|
52
|
+
const tools = withSearchTool(merged.tools);
|
|
53
|
+
// Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire
|
|
54
|
+
// on the deferred-authored-loader case, where withSearchTool returns a new array without adding one).
|
|
55
|
+
const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
|
|
56
|
+
const toolCollisions = [...discovered.collisions, ...merged.collisions];
|
|
57
|
+
// `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/): exclude pi
|
|
58
|
+
// defaults, the builtin loader (like wake, a builtin gets its own report line, not an anonymous
|
|
59
|
+
// slot in the author's list — an author-DEFINED search_tools still shows), and deferred tools —
|
|
60
|
+
// each name lives in exactly ONE report slot, and deferred names live in `deferredToolNames`.
|
|
49
61
|
const defaultNames = new Set(piDefaultTools(cwd).map((t) => t.name));
|
|
50
|
-
const toolNames = tools
|
|
51
|
-
|
|
62
|
+
const toolNames = tools
|
|
63
|
+
.filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
|
|
64
|
+
.map((t) => t.name);
|
|
65
|
+
return {
|
|
66
|
+
tools,
|
|
67
|
+
toolNames,
|
|
68
|
+
deferredToolNames: tools.filter(isDeferredTool).map((t) => t.name),
|
|
69
|
+
toolCollisions,
|
|
70
|
+
toolFailures: discovered.failures,
|
|
71
|
+
};
|
|
52
72
|
}
|
|
53
73
|
// ── §2 prompt: four-segment systemPrompt assembly ───────────────────────────
|
|
54
74
|
//
|
|
@@ -69,16 +89,25 @@ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
|
|
|
69
89
|
* `persona` (from persona.md) replaces the default identity line, keeping the tools list + guidelines.
|
|
70
90
|
*/
|
|
71
91
|
export function piBasePrompt(options = {}) {
|
|
72
|
-
const
|
|
92
|
+
const mounted = options.tools ?? [];
|
|
93
|
+
// Deferred tools stay OUT of the list: their schemas are not in the request until activated, so
|
|
94
|
+
// naming them here would invite calls to tools that don't exist yet; discovery is search_tools' job
|
|
95
|
+
// (which IS listed — it's active). Computed from the static mounted set, so the prompt — the cached
|
|
96
|
+
// context prefix — does not change when a tool is activated mid-session.
|
|
97
|
+
const tools = mounted.filter((t) => !isDeferredTool(t));
|
|
98
|
+
const deferredCount = mounted.length - tools.length;
|
|
73
99
|
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
|
|
74
100
|
// Segment ① identity: an authored persona (persona.md) replaces the default engine identity line
|
|
75
101
|
// (the standalone×code-repo cell's persona; core.md §11), keeping the tools list + guidelines below.
|
|
76
102
|
const identity = options.persona?.trim() ||
|
|
77
103
|
"You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
|
|
104
|
+
const deferredNote = deferredCount > 0
|
|
105
|
+
? `\n\n${deferredCount} additional tool(s) are registered but inactive — use search_tools to discover and activate them before concluding a capability is missing.`
|
|
106
|
+
: "";
|
|
78
107
|
return `${identity}
|
|
79
108
|
|
|
80
109
|
Available tools:
|
|
81
|
-
${toolsList}
|
|
110
|
+
${toolsList}${deferredNote}
|
|
82
111
|
|
|
83
112
|
In addition to the tools above, you may have access to other custom tools depending on the project.
|
|
84
113
|
|
|
@@ -119,6 +148,7 @@ function buildPiAgent(opts) {
|
|
|
119
148
|
env: opts.env ?? new NodeExecutionEnv({ cwd: process.cwd() }),
|
|
120
149
|
models,
|
|
121
150
|
model: resolveModel(models, opts.model),
|
|
151
|
+
thinkingLevel: opts.thinkingLevel,
|
|
122
152
|
systemPrompt: opts.systemPrompt,
|
|
123
153
|
tools: opts.tools,
|
|
124
154
|
skills: opts.skills,
|
|
@@ -145,10 +175,12 @@ function instructionsPrompt(instructions, skills) {
|
|
|
145
175
|
export function createPiAgent(options) {
|
|
146
176
|
return buildPiAgent({
|
|
147
177
|
model: options.model,
|
|
178
|
+
thinkingLevel: options.thinkingLevel,
|
|
148
179
|
providers: options.providers,
|
|
149
180
|
authPath: options.authPath,
|
|
150
181
|
systemPrompt: instructionsPrompt(options.instructions, options.skills),
|
|
151
|
-
tools
|
|
182
|
+
// Deferred tools need their loader on every rung (idempotent; the caller's own search_tools wins).
|
|
183
|
+
tools: options.tools ? withSearchTool(options.tools) : options.tools,
|
|
152
184
|
skills: options.skills,
|
|
153
185
|
sessions: options.sessions,
|
|
154
186
|
env: options.env,
|
|
@@ -177,9 +209,12 @@ export async function createPiAgentFromDefinition(dir, options) {
|
|
|
177
209
|
// runtime-written bad skill surfaces the moment it appears, while a static finding does not spam
|
|
178
210
|
// every turn's log. A log-dedup memo, not session state (stateless invoke holds).
|
|
179
211
|
let reportedFindings = findingsSignature(definition);
|
|
180
|
-
|
|
212
|
+
// Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
|
|
213
|
+
// it; a caller's own search_tools wins).
|
|
214
|
+
const tools = withSearchTool(options.tools ?? piDefaultTools(env.cwd));
|
|
181
215
|
const agent = buildPiAgent({
|
|
182
216
|
model: options.model,
|
|
217
|
+
thinkingLevel: options.thinkingLevel,
|
|
183
218
|
providers: options.providers,
|
|
184
219
|
// Dir-aware default: the same state-root-derived file the opener uses for this dir (the opener
|
|
185
220
|
// passes an explicit authPath, so this only affects direct L2 callers).
|