@fastagent-sh/fastagent 0.24.0 → 0.24.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engines/pi/agent-session-factory.js +30 -1
- package/dist/engines/pi/invoke-session.js +2 -1
- package/dist/engines/pi/models.js +7 -0
- package/dist/engines/pi/open.d.ts +4 -1
- package/dist/engines/pi/open.js +12 -3
- package/dist/engines/pi/openai-account-models.d.ts +35 -0
- package/dist/engines/pi/openai-account-models.js +116 -0
- package/dist/engines/pi/session-control.js +3 -1
- package/dist/engines/pi/turn-kit.d.ts +20 -1
- package/dist/engines/pi/turn-kit.js +22 -0
- package/dist/session.d.ts +6 -0
- package/package.json +1 -1
|
@@ -10,6 +10,8 @@ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
|
10
10
|
import { resolveModel } from "./config.js";
|
|
11
11
|
import { activePath, resolveSessionSettings } from "./session-settings.js";
|
|
12
12
|
import { DEFAULT_THINKING_LEVEL, withModelRegistration } from "./models.js";
|
|
13
|
+
import { registerAccountModels } from "./openai-account-models.js";
|
|
14
|
+
import { asksToEndRun, markEndsRun } from "./turn-kit.js";
|
|
13
15
|
import { agentSessionManager, sessionToolActivation, turnContext } from "./tool-context.js";
|
|
14
16
|
/** fastagent's tools as pi tool definitions, bound to ONE session. */
|
|
15
17
|
function toolDefinitions(tools, bound, sessionId) {
|
|
@@ -160,7 +162,7 @@ const nativeExtensions = BUILTIN_EXTENSIONS.map((name) => ({
|
|
|
160
162
|
/** The resource posture a fastagent definition asks pi for — ONE definition of it, for both assemblies. */
|
|
161
163
|
export function definitionResourceLoaderOptions(source) {
|
|
162
164
|
return {
|
|
163
|
-
extensionFactories: [...nativeExtensions, compactAdmission],
|
|
165
|
+
extensionFactories: [...nativeExtensions, compactAdmission, recordEndsRun],
|
|
164
166
|
// The machine's extensions are its owner's setup, not this agent's.
|
|
165
167
|
noExtensions: true,
|
|
166
168
|
// Explicit paths survive noExtensions, including Pi's built-ins; so they would also survive the machine's own
|
|
@@ -192,6 +194,29 @@ export function definitionResourceLoaderOptions(source) {
|
|
|
192
194
|
promptsOverride: () => ({ prompts: [...source.machine.prompts], diagnostics: [] }),
|
|
193
195
|
};
|
|
194
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Records on a tool result that it asked to end its run ({@link asksToEndRun}), so `entries()` can tell a run that
|
|
199
|
+
* ended on its tool batch from one cut after its tools. Through pi's `message_end` replacement, which runs before pi
|
|
200
|
+
* appends the message to the record. LAST among the extensions (pi appends inline factories after path ones), so a
|
|
201
|
+
* definition's own `message_end` replacement cannot drop the flag. One instance per loader, so per session.
|
|
202
|
+
*/
|
|
203
|
+
const recordEndsRun = {
|
|
204
|
+
name: "fastagent-record-ends-run",
|
|
205
|
+
hidden: true,
|
|
206
|
+
factory: (pi) => {
|
|
207
|
+
const asked = new Set();
|
|
208
|
+
pi.on("tool_execution_end", (event) => {
|
|
209
|
+
if (asksToEndRun(event))
|
|
210
|
+
asked.add(event.toolCallId);
|
|
211
|
+
});
|
|
212
|
+
pi.on("message_end", (event) => {
|
|
213
|
+
const message = event.message;
|
|
214
|
+
if (message.role !== "toolResult" || !asked.delete(message.toolCallId))
|
|
215
|
+
return;
|
|
216
|
+
return { message: markEndsRun(message) };
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
};
|
|
195
220
|
/** Manual compactions waiting for pi's admission, keyed by the session's record (what an extension's `ctx` names). */
|
|
196
221
|
const compactAdmissions = new WeakMap();
|
|
197
222
|
const COMPACT_ADMISSION = "fastagent-compact-admission";
|
|
@@ -275,6 +300,10 @@ export async function definitionServices(options) {
|
|
|
275
300
|
}),
|
|
276
301
|
extensionsOverride: admissionFirst,
|
|
277
302
|
},
|
|
303
|
+
}).then((services) => {
|
|
304
|
+
// AFTER the extensions: one that re-registers `openai` replaced the account-catalog wrapper.
|
|
305
|
+
registerAccountModels(modelRuntime);
|
|
306
|
+
return services;
|
|
278
307
|
}));
|
|
279
308
|
reportExtensionDiagnostics(services);
|
|
280
309
|
return services;
|
|
@@ -10,7 +10,7 @@ import { log } from "../../log.js";
|
|
|
10
10
|
import { toRetryScheduledEvent } from "./retry-event.js";
|
|
11
11
|
import { PortFailure, port, portAbort, portCleanup, portError } from "../../effect-port.js";
|
|
12
12
|
import { SessionBusy, acquireSession, acquireSessionLease } from "./session-effects.js";
|
|
13
|
-
import { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
|
|
13
|
+
import { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, asksToEndRun, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
|
|
14
14
|
// Structured output is for scripts. Observation publishes Pi's display result, including bounded nested traces.
|
|
15
15
|
function displayToolResult(result) {
|
|
16
16
|
const { structuredContent: _internal, ...display } = result;
|
|
@@ -78,6 +78,7 @@ function toSessionEvent(event, runId) {
|
|
|
78
78
|
isError: event.isError,
|
|
79
79
|
content: displayToolResult(event.result),
|
|
80
80
|
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
81
|
+
...(asksToEndRun(event) ? { terminate: true } : {}),
|
|
81
82
|
},
|
|
82
83
|
};
|
|
83
84
|
case "queue_update":
|
|
@@ -13,9 +13,13 @@ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, globalHome, resolveOverridePath } from "../../paths.js";
|
|
14
14
|
import { writeFileAtomic } from "../../atomic-write.js";
|
|
15
15
|
import { withLockedFile } from "./locked-file.js";
|
|
16
|
+
import { OPENAI_PROVIDER, registerAccountModels, withAccountModels } from "./openai-account-models.js";
|
|
16
17
|
/** The built-ins plus `providers` (a same id replaces a built-in), over any credential store. */
|
|
17
18
|
export function piModelsOver(credentials, providers = []) {
|
|
18
19
|
const models = builtinModels({ credentials, authContext: defaultProviderAuthContext() });
|
|
20
|
+
const openai = models.getProvider(OPENAI_PROVIDER);
|
|
21
|
+
if (openai)
|
|
22
|
+
models.setProvider(withAccountModels(openai));
|
|
19
23
|
for (const provider of providers)
|
|
20
24
|
models.setProvider(provider);
|
|
21
25
|
return models;
|
|
@@ -281,6 +285,8 @@ export async function machineModelRuntime(options = {}) {
|
|
|
281
285
|
const error = runtime.getError();
|
|
282
286
|
if (error)
|
|
283
287
|
throw new Error(error);
|
|
288
|
+
// A machine catalog refresh may refresh an OAuth token, which must carry the account's catalog over.
|
|
289
|
+
await withModelRegistration(runtime, async () => registerAccountModels(runtime));
|
|
284
290
|
return runtime;
|
|
285
291
|
}
|
|
286
292
|
/** How long a catalog refresh may take, as `pi update --models` allows. */
|
|
@@ -383,6 +389,7 @@ export async function createPiModelRuntime(options) {
|
|
|
383
389
|
throw new Error(`${error}${origin}`);
|
|
384
390
|
}
|
|
385
391
|
await withModelRegistration(runtime, async () => {
|
|
392
|
+
registerAccountModels(runtime);
|
|
386
393
|
for (const provider of options.providers ?? [])
|
|
387
394
|
runtime.registerNativeProvider(provider);
|
|
388
395
|
});
|
|
@@ -101,9 +101,12 @@ export declare function assembleFront(front: AgentAssembly, extra?: {
|
|
|
101
101
|
* `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
|
|
102
102
|
* directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
|
|
103
103
|
* authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
|
|
104
|
+
* A Sign in with ChatGPT on `openai` lists the account's own models (openai-account-models.ts), not pi's built-ins.
|
|
104
105
|
*
|
|
105
106
|
* `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
|
|
106
|
-
* configured", so a client that must not show that as an empty list passes a sink that throws.
|
|
107
|
+
* configured", so a client that must not show that as an empty list passes a sink that throws. A ChatGPT sign-in that
|
|
108
|
+
* carries no catalog is a normal, recoverable state (signed in before the catalog was read), not that: it is logged,
|
|
109
|
+
* never sent to `warn`, so a throwing sink does not fail the whole listing for it.
|
|
107
110
|
*/
|
|
108
111
|
export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<ModelDescriptor[]>;
|
|
109
112
|
/**
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -17,6 +17,8 @@ import { reportFindingsIfChanged } from "./report.js";
|
|
|
17
17
|
import { readMachine, withMachine } from "./machine.js";
|
|
18
18
|
import { createPiModelRuntime, globalCatalogPath, machineModelRuntime, refreshCatalog } from "./models.js";
|
|
19
19
|
import { agentModels } from "./agent-models.js";
|
|
20
|
+
import { OPENAI_PROVIDER, missingAccountModels } from "./openai-account-models.js";
|
|
21
|
+
import { log } from "../../log.js";
|
|
20
22
|
import { piSessionRecordStore } from "./session-store.js";
|
|
21
23
|
import { gateSecrets } from "../../secrets-gate.js";
|
|
22
24
|
/**
|
|
@@ -126,14 +128,21 @@ export function assembleFront(front, extra = {}) {
|
|
|
126
128
|
* `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
|
|
127
129
|
* directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
|
|
128
130
|
* authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
|
|
131
|
+
* A Sign in with ChatGPT on `openai` lists the account's own models (openai-account-models.ts), not pi's built-ins.
|
|
129
132
|
*
|
|
130
133
|
* `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
|
|
131
|
-
* configured", so a client that must not show that as an empty list passes a sink that throws.
|
|
134
|
+
* configured", so a client that must not show that as an empty list passes a sink that throws. A ChatGPT sign-in that
|
|
135
|
+
* carries no catalog is a normal, recoverable state (signed in before the catalog was read), not that: it is logged,
|
|
136
|
+
* never sent to `warn`, so a throwing sink does not fail the whole listing for it.
|
|
132
137
|
*/
|
|
133
138
|
export async function availableModelsFromDir(dir, options = {}) {
|
|
134
139
|
const { agentDir, workspace } = resolvePlacement(dir);
|
|
135
|
-
const
|
|
136
|
-
|
|
140
|
+
const environment = agentModels(agentDir, options, { cwd: workspace });
|
|
141
|
+
const available = await (await environment.runtime()).getAvailable();
|
|
142
|
+
const missing = missingAccountModels(await environment.credentials.read(OPENAI_PROVIDER));
|
|
143
|
+
if (missing)
|
|
144
|
+
log.warn(missing);
|
|
145
|
+
return describeModels(available);
|
|
137
146
|
}
|
|
138
147
|
/**
|
|
139
148
|
* Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The models a Sign in with ChatGPT can run on the `openai` provider: the ACCOUNT's catalog, which OpenAI publishes at
|
|
3
|
+
* `GET /v1/models` for the same access token, not pi's built-in list (most of which such an account is refused).
|
|
4
|
+
*
|
|
5
|
+
* pi lists a provider's models through `filterModels`, which is synchronous, so the catalog is read when there is a
|
|
6
|
+
* token to read it with (login and every token refresh) and kept on the credential, like the account it belongs to:
|
|
7
|
+
* a new account brings its own list, and nothing per-account lands in the definition's model catalog. pi's `openai`
|
|
8
|
+
* provider does not do this itself (checked against pi 0.99.2), so {@link withAccountModels} wraps it. An API key on the same
|
|
9
|
+
* provider keeps the built-in list.
|
|
10
|
+
*/
|
|
11
|
+
import type { Credential, Provider } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
export declare const OPENAI_PROVIDER = "openai";
|
|
14
|
+
/**
|
|
15
|
+
* Why the stored `openai` credential lists no models, when that is because its sign-in carries no catalog: the one
|
|
16
|
+
* wording a listing reports instead of falling back to every built-in model.
|
|
17
|
+
*/
|
|
18
|
+
export declare function missingAccountModels(credential: Credential | undefined): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* `provider` with the account catalog: read at login and at each token refresh, and the only models a ChatGPT
|
|
21
|
+
* sign-in lists. Anything without OAuth is returned as is.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withAccountModels(provider: Provider): Provider;
|
|
24
|
+
/**
|
|
25
|
+
* Put {@link withAccountModels} on a runtime's `openai`, over the provider the runtime composed (pi's remote catalog,
|
|
26
|
+
* the agent's models.json, and an extension's `registerProvider("openai", …)` included), so none of them is lost. pi
|
|
27
|
+
* composes a native provider with models.json once more; that second pass is idempotent today, and
|
|
28
|
+
* openai-account-models.test.ts holds it to that. Call it inside `withModelRegistration`, and again after extensions
|
|
29
|
+
* load: pi's `registerProvider` drops a native provider, and a refresh without the wrapper would store a credential
|
|
30
|
+
* without its catalog. Repeating it is harmless.
|
|
31
|
+
*
|
|
32
|
+
* Known ceiling: an extension that registers `openai` later than its load (from an event handler) drops the wrapper
|
|
33
|
+
* for that runtime.
|
|
34
|
+
*/
|
|
35
|
+
export declare function registerAccountModels(runtime: ModelRuntime): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { log } from "../../log.js";
|
|
2
|
+
export const OPENAI_PROVIDER = "openai";
|
|
3
|
+
const ACCOUNT_MODELS_URL = "https://api.openai.com/v1/models";
|
|
4
|
+
/** Where the catalog is kept on the stored credential: the model ids the account lists, in OpenAI's order. */
|
|
5
|
+
const ACCOUNT_MODELS_FIELD = "accountModels";
|
|
6
|
+
/** A token refresh is bounded at 15s by pi; the catalog read must leave it room to finish and be stored. */
|
|
7
|
+
const CATALOG_TIMEOUT_MS = 5_000;
|
|
8
|
+
/** The slugs the account lists for a picker (`visibility: "list"`), in the server's order. */
|
|
9
|
+
async function fetchAccountModels(access, signal) {
|
|
10
|
+
const timeout = AbortSignal.timeout(CATALOG_TIMEOUT_MS);
|
|
11
|
+
const response = await fetch(ACCOUNT_MODELS_URL, {
|
|
12
|
+
headers: { accept: "application/json", authorization: `Bearer ${access}` },
|
|
13
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
const body = await response.text().catch(() => "");
|
|
17
|
+
throw new Error(`GET ${ACCOUNT_MODELS_URL} answered ${response.status}: ${body || response.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
const models = (await response.json())?.models;
|
|
20
|
+
if (!Array.isArray(models))
|
|
21
|
+
throw new Error(`GET ${ACCOUNT_MODELS_URL} answered without a "models" array`);
|
|
22
|
+
return models.flatMap((entry) => entry?.visibility === "list" && typeof entry.slug === "string" ? [entry.slug] : []);
|
|
23
|
+
}
|
|
24
|
+
/** The catalog a ChatGPT sign-in carries, or undefined when it carries none (signed in before it was read). */
|
|
25
|
+
function accountModels(credential) {
|
|
26
|
+
const ids = credential[ACCOUNT_MODELS_FIELD];
|
|
27
|
+
return Array.isArray(ids) && ids.every((id) => typeof id === "string") ? ids : undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Why the stored `openai` credential lists no models, when that is because its sign-in carries no catalog: the one
|
|
31
|
+
* wording a listing reports instead of falling back to every built-in model.
|
|
32
|
+
*/
|
|
33
|
+
export function missingAccountModels(credential) {
|
|
34
|
+
if (credential?.type !== "oauth" || accountModels(credential))
|
|
35
|
+
return undefined;
|
|
36
|
+
return ("[fastagent] the ChatGPT sign-in for openai carries no model catalog, so it lists no models: it is read at " +
|
|
37
|
+
"login and on each token refresh. Run `fastagent login` to read it now");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `provider` with the account catalog: read at login and at each token refresh, and the only models a ChatGPT
|
|
41
|
+
* sign-in lists. Anything without OAuth is returned as is.
|
|
42
|
+
*/
|
|
43
|
+
export function withAccountModels(provider) {
|
|
44
|
+
const oauth = provider.auth.oauth;
|
|
45
|
+
if (!oauth)
|
|
46
|
+
return provider;
|
|
47
|
+
const wrapped = {
|
|
48
|
+
...oauth,
|
|
49
|
+
async login(interaction, options) {
|
|
50
|
+
const credential = await oauth.login(interaction, options);
|
|
51
|
+
try {
|
|
52
|
+
return {
|
|
53
|
+
...credential,
|
|
54
|
+
[ACCOUNT_MODELS_FIELD]: await fetchAccountModels(credential.access, interaction.signal),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// The sign-in itself worked and inference needs nothing more: keep it, and say the list is empty until the
|
|
59
|
+
// next token refresh reads the catalog.
|
|
60
|
+
interaction.notify({
|
|
61
|
+
type: "info",
|
|
62
|
+
message: `could not read this ChatGPT account's model catalog (${error.message}); it lists no models until the next token refresh or login`,
|
|
63
|
+
});
|
|
64
|
+
return credential;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async refresh(credential, signal) {
|
|
68
|
+
const next = await oauth.refresh(credential, signal);
|
|
69
|
+
try {
|
|
70
|
+
return { ...next, [ACCOUNT_MODELS_FIELD]: await fetchAccountModels(next.access, signal) };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// NEVER fail here: the refresh token was just rotated, and a throw would leave the new one unstored and the
|
|
74
|
+
// old one spent. The previous catalog stands.
|
|
75
|
+
log.warn(`[fastagent] could not refresh the ChatGPT account's model catalog: ${error.message}`);
|
|
76
|
+
const previous = accountModels(credential);
|
|
77
|
+
return previous ? { ...next, [ACCOUNT_MODELS_FIELD]: previous } : next;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
...provider,
|
|
83
|
+
auth: { ...provider.auth, oauth: wrapped },
|
|
84
|
+
filterModels: (models, credential) => {
|
|
85
|
+
const base = provider.filterModels?.(models, credential) ?? models;
|
|
86
|
+
if (credential?.type !== "oauth")
|
|
87
|
+
return base;
|
|
88
|
+
const listed = new Set(accountModels(credential) ?? []);
|
|
89
|
+
return base.filter((model) => listed.has(model.id));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Providers this module built, so a second registration leaves them as they are instead of wrapping them again. */
|
|
94
|
+
const wrappedProviders = new WeakSet();
|
|
95
|
+
/**
|
|
96
|
+
* Put {@link withAccountModels} on a runtime's `openai`, over the provider the runtime composed (pi's remote catalog,
|
|
97
|
+
* the agent's models.json, and an extension's `registerProvider("openai", …)` included), so none of them is lost. pi
|
|
98
|
+
* composes a native provider with models.json once more; that second pass is idempotent today, and
|
|
99
|
+
* openai-account-models.test.ts holds it to that. Call it inside `withModelRegistration`, and again after extensions
|
|
100
|
+
* load: pi's `registerProvider` drops a native provider, and a refresh without the wrapper would store a credential
|
|
101
|
+
* without its catalog. Repeating it is harmless.
|
|
102
|
+
*
|
|
103
|
+
* Known ceiling: an extension that registers `openai` later than its load (from an event handler) drops the wrapper
|
|
104
|
+
* for that runtime.
|
|
105
|
+
*/
|
|
106
|
+
export function registerAccountModels(runtime) {
|
|
107
|
+
const registered = runtime.getRegisteredNativeProvider(OPENAI_PROVIDER);
|
|
108
|
+
if (registered && wrappedProviders.has(registered))
|
|
109
|
+
return;
|
|
110
|
+
const provider = runtime.getProvider(OPENAI_PROVIDER);
|
|
111
|
+
if (!provider)
|
|
112
|
+
return;
|
|
113
|
+
const wrapped = withAccountModels(provider);
|
|
114
|
+
wrappedProviders.add(wrapped);
|
|
115
|
+
runtime.registerNativeProvider(wrapped);
|
|
116
|
+
}
|
|
@@ -12,7 +12,7 @@ import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
|
12
12
|
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
13
13
|
import { forkProvenance, isConversationMessage, isNavigable, publishedLeaf } from "./session-markers.js";
|
|
14
14
|
import { entryImages, imageAt } from "./entry-images.js";
|
|
15
|
-
import { answerOutcome } from "./turn-kit.js";
|
|
15
|
+
import { answerOutcome, endsRun } from "./turn-kit.js";
|
|
16
16
|
import { startCompaction } from "./agent-session-factory.js";
|
|
17
17
|
import { toRetryScheduledEvent } from "./retry-event.js";
|
|
18
18
|
import { THINKING_LEVELS, activePath, describeModels, resolveSessionSettings } from "./session-settings.js";
|
|
@@ -107,6 +107,8 @@ function toSessionEntry(entry, parentId) {
|
|
|
107
107
|
isError: m.isError ?? false,
|
|
108
108
|
text: contentText(m.content, ""),
|
|
109
109
|
...entryImages(entry.id, m),
|
|
110
|
+
// Same flag as the live `tool_finished` (turn-kit.ts asksToEndRun).
|
|
111
|
+
...(endsRun(m) ? { terminate: true } : {}),
|
|
110
112
|
},
|
|
111
113
|
};
|
|
112
114
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AssistantMessage, type ImageContent } from "@earendil-works/pi-ai";
|
|
1
|
+
import { type AssistantMessage, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
2
|
import { type AgentEvent, type Prompt } from "../../agent.ts";
|
|
3
3
|
import type { AnswerOutcome, PromptDisposition, SessionEvent } from "../../session.ts";
|
|
4
4
|
export type Release = () => void;
|
|
@@ -18,6 +18,25 @@ export declare function classifyRetryable(details: string, signal: {
|
|
|
18
18
|
* the recorded one cannot disagree.
|
|
19
19
|
*/
|
|
20
20
|
export declare function answerOutcome(message: AssistantMessage): AnswerOutcome | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a finished tool call asked to end its run (pi's `terminate`: returned by the tool, or set by a `tool_call`
|
|
23
|
+
* hook that blocks it; pi 0.99.2's `tool_result` hook cannot set it). After a batch whose EVERY result asks it, pi
|
|
24
|
+
* makes no further model call for that batch, so unless a queued steer or follow-up continues the run, it completes on
|
|
25
|
+
* `tool` entries with no answer after them: the same shape a process killed after its tools leaves. A call made from
|
|
26
|
+
* inside another tool (`parentToolCallId`, a codemode script) is not in the batch and does not count.
|
|
27
|
+
* The ONE reading: the live `tool_finished` and the flag {@link markEndsRun} records both come from it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function asksToEndRun(event: {
|
|
30
|
+
result?: unknown;
|
|
31
|
+
parentToolCallId?: string;
|
|
32
|
+
}): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The tool result message as pi then records it, carrying the request: pi's `createToolResultMessage` drops
|
|
35
|
+
* `terminate` (as of pi 0.99.2), so without this nothing in the record says the run ended on purpose.
|
|
36
|
+
*/
|
|
37
|
+
export declare function markEndsRun(message: ToolResultMessage): ToolResultMessage;
|
|
38
|
+
/** Whether a recorded tool result carries {@link markEndsRun}'s flag. */
|
|
39
|
+
export declare function endsRun(message: ToolResultMessage): boolean;
|
|
21
40
|
/**
|
|
22
41
|
* Terminal mapping, decided by the resolved message's outcome: pi's `prompt()` RESOLVES a message with stopReason
|
|
23
42
|
* "error"/"aborted" rather than throwing, so relying on catch alone would miss that whole failure class and violate
|
|
@@ -91,6 +91,28 @@ export function answerOutcome(message) {
|
|
|
91
91
|
return undefined;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a finished tool call asked to end its run (pi's `terminate`: returned by the tool, or set by a `tool_call`
|
|
96
|
+
* hook that blocks it; pi 0.99.2's `tool_result` hook cannot set it). After a batch whose EVERY result asks it, pi
|
|
97
|
+
* makes no further model call for that batch, so unless a queued steer or follow-up continues the run, it completes on
|
|
98
|
+
* `tool` entries with no answer after them: the same shape a process killed after its tools leaves. A call made from
|
|
99
|
+
* inside another tool (`parentToolCallId`, a codemode script) is not in the batch and does not count.
|
|
100
|
+
* The ONE reading: the live `tool_finished` and the flag {@link markEndsRun} records both come from it.
|
|
101
|
+
*/
|
|
102
|
+
export function asksToEndRun(event) {
|
|
103
|
+
return !event.parentToolCallId && event.result?.terminate === true;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The tool result message as pi then records it, carrying the request: pi's `createToolResultMessage` drops
|
|
107
|
+
* `terminate` (as of pi 0.99.2), so without this nothing in the record says the run ended on purpose.
|
|
108
|
+
*/
|
|
109
|
+
export function markEndsRun(message) {
|
|
110
|
+
return { ...message, terminate: true };
|
|
111
|
+
}
|
|
112
|
+
/** Whether a recorded tool result carries {@link markEndsRun}'s flag. */
|
|
113
|
+
export function endsRun(message) {
|
|
114
|
+
return message.terminate === true;
|
|
115
|
+
}
|
|
94
116
|
/**
|
|
95
117
|
* Terminal mapping, decided by the resolved message's outcome: pi's `prompt()` RESOLVES a message with stopReason
|
|
96
118
|
* "error"/"aborted" rather than throwing, so relying on catch alone would miss that whole failure class and violate
|
package/dist/session.d.ts
CHANGED
|
@@ -388,11 +388,17 @@ export type ToolProgressEvent = SessionEvent<"tool_progress", {
|
|
|
388
388
|
}> & {
|
|
389
389
|
runId: string;
|
|
390
390
|
};
|
|
391
|
+
/**
|
|
392
|
+
* `terminate: true` when the call asked to end its run. Once every call answering one assistant message asks it, the
|
|
393
|
+
* model is not called again for them, and the run ends there with no answer after its `tool` entries (which carry the
|
|
394
|
+
* same flag) unless a queued steer or follow-up continues it. Whether the run ended is `run_settled`'s to say.
|
|
395
|
+
*/
|
|
391
396
|
export type ToolFinishedEvent = SessionEvent<"tool_finished", {
|
|
392
397
|
id: string;
|
|
393
398
|
isError: boolean;
|
|
394
399
|
content: Json;
|
|
395
400
|
parentToolCallId?: string;
|
|
401
|
+
terminate?: true;
|
|
396
402
|
}> & {
|
|
397
403
|
runId: string;
|
|
398
404
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/fastagent",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.2",
|
|
4
4
|
"description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on Telegram, Slack, Feishu, or behind any channel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|