@fastagent-sh/fastagent 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +11 -0
- package/dist/channels/feishu/feishu-api.d.ts +4 -2
- package/dist/channels/feishu/feishu.js +39 -9
- package/dist/channels/feishu/invoke-turn.d.ts +8 -2
- package/dist/channels/feishu/invoke-turn.js +150 -31
- package/dist/channels/feishu/parse.js +6 -0
- package/dist/channels/http.js +15 -2
- package/dist/channels/invoke-turn-kit.d.ts +5 -2
- package/dist/channels/invoke-turn-kit.js +6 -2
- package/dist/channels/slack/invoke-turn.js +1 -1
- package/dist/channels/slack/slack.js +1 -5
- package/dist/channels/state.d.ts +0 -10
- package/dist/channels/state.js +2 -19
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/thread-participants.d.ts +7 -0
- package/dist/channels/thread-participants.js +3 -0
- package/dist/cli/commands/deploy.js +13 -5
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +21 -1
- package/dist/cli/commands/invoke.js +1 -1
- package/dist/cli/commands/start.js +1 -1
- package/dist/cli/shared.d.ts +4 -2
- package/dist/cli/shared.js +12 -5
- package/dist/collect.d.ts +30 -4
- package/dist/collect.js +39 -6
- package/dist/deploy/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +21 -3
- package/dist/deploy/secrets.d.ts +3 -0
- package/dist/deploy/secrets.js +6 -0
- package/dist/dev-supervisor.js +8 -2
- package/dist/engines/pi/create.d.ts +2 -1
- package/dist/engines/pi/create.js +12 -7
- package/dist/engines/pi/harness.d.ts +6 -3
- package/dist/engines/pi/harness.js +4 -3
- package/dist/engines/pi/invoke-session.d.ts +32 -0
- package/dist/engines/pi/invoke-session.js +171 -0
- package/dist/engines/pi/invoke.d.ts +6 -27
- package/dist/engines/pi/invoke.js +49 -208
- package/dist/engines/pi/models.d.ts +45 -11
- package/dist/engines/pi/models.js +55 -8
- package/dist/engines/pi/session-builder.js +4 -2
- package/dist/engines/pi/session-control.d.ts +2 -1
- package/dist/engines/pi/sessions.d.ts +17 -1
- package/dist/engines/pi/sessions.js +292 -10
- package/dist/engines/pi/turn-kit.d.ts +56 -0
- package/dist/engines/pi/turn-kit.js +161 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +6 -0
- package/dist/pi.d.ts +3 -2
- package/dist/pi.js +1 -1
- package/dist/scaffold/templates/fastagent.config.mjs +2 -0
- package/dist/session-remote.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L0 over pi-coding-agent's `AgentSession`, in the `per-invoke` state locality
|
|
3
|
+
* ([conformance-levels.md](../../../docs/design/conformance-levels.md) §2, top-right cell): build a
|
|
4
|
+
* session per invoke over the SAME durable jsonl, run one turn, dispose.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists next to {@link createPiAgentFromHarness}: pi 0.84 replaced `AgentHarness` with an
|
|
7
|
+
* unimplemented lane-based skeleton, and pi does not consume that class itself — its TUI, RPC and SDK
|
|
8
|
+
* all run on `AgentSession`. This is the executable proof that the SPEC's four Agent-side MUSTs hold
|
|
9
|
+
* on the class pi actually maintains (test/conformance-session.test.ts).
|
|
10
|
+
*
|
|
11
|
+
* SCOPE, deliberately narrow: the concurrency floor, the event stream, and cancellation. The
|
|
12
|
+
* observation plane (SessionObserver / RunControls / the rich `SessionEvent` vocabulary), the tool
|
|
13
|
+
* activation bridge, auto-compaction and session inheritance are NOT wired.
|
|
14
|
+
*
|
|
15
|
+
* WHICH L0 SERVES: {@link createPiAgentFromHarness}, still — this one is reachable only from its
|
|
16
|
+
* conformance test (deliberately absent from `src/pi.ts`), because a serving path needs the pieces
|
|
17
|
+
* above. Its one known debt is {@link toAgentEvent}: translating pi events straight to SPEC
|
|
18
|
+
* `AgentEvent`s is the second parallel translation `docs/design/session-control.md` §6 forbids. It
|
|
19
|
+
* retires the moment this L0 grows the observation plane — the rich `SessionEvent` layer comes back
|
|
20
|
+
* with it, and the harness L0 goes away.
|
|
21
|
+
*/
|
|
22
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { type Agent } from "../../agent.ts";
|
|
24
|
+
import { type Lease } from "./turn-kit.ts";
|
|
25
|
+
/** Open-or-create the session behind `sessionId` and bind an `AgentSession` to it, per invoke. */
|
|
26
|
+
export type PiAgentSessionFactory = (sessionId: string) => Promise<AgentSession>;
|
|
27
|
+
export interface CreatePiAgentFromSessionOptions {
|
|
28
|
+
sessionFactory: PiAgentSessionFactory;
|
|
29
|
+
/** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
|
|
30
|
+
lease?: Lease;
|
|
31
|
+
}
|
|
32
|
+
export declare function createPiAgentFromSession(options: CreatePiAgentFromSessionOptions): Agent;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
2
|
+
import { cancellableStream } from "../../collect.js";
|
|
3
|
+
import { log } from "../../log.js";
|
|
4
|
+
import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
|
|
5
|
+
/**
|
|
6
|
+
* SPEC events from pi's session stream. `auto_retry_start` has no harness counterpart: an
|
|
7
|
+
* `AgentSession` retries a failed assistant request itself, which the SPEC already has a word for.
|
|
8
|
+
*/
|
|
9
|
+
function toAgentEvent(event) {
|
|
10
|
+
switch (event.type) {
|
|
11
|
+
case "message_update": {
|
|
12
|
+
// An empty delta is not output: it moves no consumer's state, and treating it as output would
|
|
13
|
+
// spend the silent window that auto-retry is allowed to use (see runOnSession).
|
|
14
|
+
const ev = event.assistantMessageEvent;
|
|
15
|
+
if (ev.type === "text_delta")
|
|
16
|
+
return ev.delta === "" ? null : { type: "text", delta: ev.delta };
|
|
17
|
+
if (ev.type === "thinking_delta")
|
|
18
|
+
return ev.delta === "" ? null : { type: "thinking", delta: ev.delta };
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
case "tool_execution_start":
|
|
22
|
+
return { type: "tool_started", id: event.toolCallId, name: event.toolName, args: event.args };
|
|
23
|
+
case "tool_execution_end":
|
|
24
|
+
return { type: "tool_ended", id: event.toolCallId, isError: event.isError, content: event.result };
|
|
25
|
+
case "auto_retry_start":
|
|
26
|
+
return {
|
|
27
|
+
type: "retrying",
|
|
28
|
+
attempt: event.attempt,
|
|
29
|
+
maxAttempts: event.maxAttempts,
|
|
30
|
+
delayMs: event.delayMs,
|
|
31
|
+
reason: event.errorMessage,
|
|
32
|
+
};
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The turn's outcome. `prompt()` resolves void and never throws for an engine-side failure (measured:
|
|
39
|
+
* a provider error and an abort both resolve normally), so the terminal comes from the assistant
|
|
40
|
+
* message the run ended on — the same read {@link toTerminal} performs on the harness path.
|
|
41
|
+
*
|
|
42
|
+
* That message is taken from the EVENT STREAM, not from `session.state.messages`. Session state is
|
|
43
|
+
* mutable mid-turn: compaction replaces the array, and overflow recovery splices the last assistant
|
|
44
|
+
* message out of it outright (`state.messages = messages.slice(0, -1)`). Any index into it is a
|
|
45
|
+
* turn boundary that the engine is free to invalidate, while a `message_end` payload is a fact that
|
|
46
|
+
* already happened. Auto-compaction runs its own model call outside the agent's event stream, so it
|
|
47
|
+
* cannot masquerade as the turn's answer here.
|
|
48
|
+
*/
|
|
49
|
+
const ENGINE_PRODUCED_NOTHING = {
|
|
50
|
+
// Unreachable on a settled run: pi ends every outcome, error and abort included, with an assistant
|
|
51
|
+
// message. Reaching it means the engine broke its own contract — name the engine, not the turn.
|
|
52
|
+
type: "failed",
|
|
53
|
+
details: "the engine settled the run without ending an assistant message",
|
|
54
|
+
retryable: false,
|
|
55
|
+
};
|
|
56
|
+
export function createPiAgentFromSession(options) {
|
|
57
|
+
const { sessionFactory, lease = inProcessLease() } = options;
|
|
58
|
+
/** Own the session's lifetime: one writer, built here, disposed here whatever the turn did. */
|
|
59
|
+
async function* turn(scope, prompt, hooks) {
|
|
60
|
+
const release = lease.tryAcquire(scope.session);
|
|
61
|
+
if (!release) {
|
|
62
|
+
yield {
|
|
63
|
+
type: "failed",
|
|
64
|
+
details: "session busy: a turn is already in flight for this session",
|
|
65
|
+
retryable: true,
|
|
66
|
+
code: SESSION_BUSY_CODE,
|
|
67
|
+
};
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
let session;
|
|
72
|
+
try {
|
|
73
|
+
session = await sessionFactory(scope.session);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
yield errorToTerminal(error); // setup failures are events, never throws (MUST 2)
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
yield* runOnSession(session, prompt, hooks);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
try {
|
|
84
|
+
session.dispose();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
log.warn(`[fastagent] session dispose failed during cleanup: ${String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { invoke: (scope, prompt) => cancellableStream((hooks) => turn(scope, prompt, hooks)) };
|
|
96
|
+
}
|
|
97
|
+
/** One turn on a session someone else owns: subscribe, prompt, stream, terminal. */
|
|
98
|
+
async function* runOnSession(session, prompt, { onCancelReady, wasCancelled }) {
|
|
99
|
+
const abort = () => session.abort().catch(() => { });
|
|
100
|
+
onCancelReady(() => void abort());
|
|
101
|
+
const queue = new EventQueue();
|
|
102
|
+
let finalAssistant;
|
|
103
|
+
/** Whether any of THIS attempt's answer has been streamed — the only output a retry would duplicate. */
|
|
104
|
+
let streamedAnswer = false;
|
|
105
|
+
/** Set when a retry is refused because the answer already streamed — carries the error that ends it. */
|
|
106
|
+
let retriedAfterAnswer;
|
|
107
|
+
const unsub = session.subscribe((event) => {
|
|
108
|
+
if (retriedAfterAnswer !== undefined)
|
|
109
|
+
return; // the turn is decided; the retry's output is not ours
|
|
110
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
111
|
+
finalAssistant = event.message;
|
|
112
|
+
}
|
|
113
|
+
// pi retries a failed assistant request by DISCARDING that attempt's assistant message and asking
|
|
114
|
+
// again. Everything the turn achieved before it survives - executed tools keep their persisted
|
|
115
|
+
// results, and the retry resumes from them - so the only thing a retry can duplicate is answer
|
|
116
|
+
// text this L0 already streamed, which SPEC deltas cannot retract. Refuse it exactly there:
|
|
117
|
+
// refusing on tool events instead would push the retry out to the CALLER, who can only re-run the
|
|
118
|
+
// whole prompt and execute the tool a second time.
|
|
119
|
+
if (event.type === "auto_retry_start" && streamedAnswer) {
|
|
120
|
+
retriedAfterAnswer = event.errorMessage;
|
|
121
|
+
// Not synchronously: pi emits this event BEFORE creating the controller that makes its backoff
|
|
122
|
+
// abortable, so an abort from inside the listener would find nothing to cancel and the turn
|
|
123
|
+
// would still pay the full delay and burn a provider call on an answer we must discard.
|
|
124
|
+
queueMicrotask(() => void abort());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const projected = toAgentEvent(event);
|
|
128
|
+
if (!projected)
|
|
129
|
+
return;
|
|
130
|
+
if (projected.type === "text" || projected.type === "thinking")
|
|
131
|
+
streamedAnswer = true;
|
|
132
|
+
queue.push(projected);
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
// Resolving prompt options lazy-loads the image pipeline and re-encodes every attachment, so it
|
|
136
|
+
// both takes time and can throw before any engine work exists to fail. Hence the two guards, in
|
|
137
|
+
// this order and no earlier: its failure is a turn failure (MUST 2), and the latch has to be read
|
|
138
|
+
// after the LAST await before the call — the door armed above only stops a RUNNING session, so a
|
|
139
|
+
// consumer who walked away during the build or the resize would knock on an idle one and have
|
|
140
|
+
// the turn start anyway.
|
|
141
|
+
let options;
|
|
142
|
+
try {
|
|
143
|
+
options = await toPiPromptOptions(prompt);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
yield errorToTerminal(error);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (wasCancelled()) {
|
|
150
|
+
await abort();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const run = session.prompt(prompt.text, options);
|
|
154
|
+
yield* queue.drainUntil(run);
|
|
155
|
+
try {
|
|
156
|
+
await run;
|
|
157
|
+
if (retriedAfterAnswer !== undefined) {
|
|
158
|
+
yield { type: "failed", details: retriedAfterAnswer, retryable: true };
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
yield finalAssistant ? toTerminal(finalAssistant) : ENGINE_PRODUCED_NOTHING;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
yield errorToTerminal(error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
unsub();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
2
|
+
* The harness L0: fan pi AgentHarness's two ports (subscribe event side-channel + prompt final
|
|
3
|
+
* value) into SPEC's single event stream, under a single-writer-per-session lease.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* §4 createPiAgentFromHarness — composes §1–§3 into Agent.invoke
|
|
5
|
+
* The half that does not care which pi class runs the turn — lease, terminals, event queue, prompt
|
|
6
|
+
* prep — is turn-kit.ts. What lives here is what only the harness has: its event vocabulary, translated ONCE
|
|
7
|
+
* into the rich `SessionEvent` layer, and the observation plane that layer feeds.
|
|
9
8
|
*
|
|
10
9
|
* Concurrency: at most one in-flight turn per session; a second invoke fails fast with
|
|
11
10
|
* `failed{retryable}` ("session busy"), leaving dedupe/queueing/steering to the channel. Each
|
|
12
11
|
* invoke builds a fresh harness bound to the session and discards it (stateless multi-session).
|
|
13
12
|
*/
|
|
14
13
|
import type { AgentHarnessEvent } from "@earendil-works/pi-agent-core";
|
|
15
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
16
14
|
import { type Agent, type AgentEvent, type Prompt } from "../../agent.ts";
|
|
17
15
|
import type { SessionEvent } from "../../session.ts";
|
|
18
16
|
import { type PiHarnessFactory } from "./harness.ts";
|
|
19
|
-
|
|
20
|
-
export interface Lease {
|
|
21
|
-
/** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
|
|
22
|
-
tryAcquire(session: string): Release | null;
|
|
23
|
-
}
|
|
24
|
-
export declare function inProcessLease(): Lease;
|
|
25
|
-
/** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
|
|
26
|
-
export declare function classifyRetryable(details: string, signal: {
|
|
27
|
-
status?: number;
|
|
28
|
-
code?: unknown;
|
|
29
|
-
}): boolean;
|
|
17
|
+
import { type Lease } from "./turn-kit.ts";
|
|
30
18
|
/**
|
|
31
19
|
* In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
|
|
32
20
|
* the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
|
|
@@ -58,15 +46,6 @@ export interface RunControls {
|
|
|
58
46
|
* modulation handles — it is the trusted hub seam, not a public fan-out point. Do not wire
|
|
59
47
|
* untrusted taps here; give third parties the read-only `events()` stream instead. */
|
|
60
48
|
export type SessionObserver = (session: string, event: SessionEvent, run?: RunControls) => void;
|
|
61
|
-
/**
|
|
62
|
-
* Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
|
|
63
|
-
* with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
|
|
64
|
-
* entire failure class (violating SPEC MUST 1).
|
|
65
|
-
*/
|
|
66
|
-
export declare function toTerminal(message: AssistantMessage): AgentEvent;
|
|
67
|
-
export declare function errorToTerminal(error: unknown): Extract<AgentEvent, {
|
|
68
|
-
type: "failed";
|
|
69
|
-
}>;
|
|
70
49
|
export interface CreatePiAgentFromHarnessOptions {
|
|
71
50
|
harnessFactory: PiHarnessFactory;
|
|
72
51
|
/** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
|
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
|
|
3
|
-
import {
|
|
3
|
+
import { cancellableStream } from "../../collect.js";
|
|
4
4
|
import { log } from "../../log.js";
|
|
5
5
|
import { TOOL_ACTIVATION_ENTRY, harnessSession } from "./harness.js";
|
|
6
|
+
import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
|
|
6
7
|
import { additiveActivation, turnContext } from "./tool-context.js";
|
|
7
|
-
|
|
8
|
-
const busy = new Set();
|
|
9
|
-
return {
|
|
10
|
-
tryAcquire(session) {
|
|
11
|
-
if (busy.has(session))
|
|
12
|
-
return null;
|
|
13
|
-
busy.add(session);
|
|
14
|
-
let released = false;
|
|
15
|
-
return () => {
|
|
16
|
-
if (released)
|
|
17
|
-
return;
|
|
18
|
-
released = true;
|
|
19
|
-
busy.delete(session);
|
|
20
|
-
};
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
// ── §2 translate: the single pi↔SPEC translation point ───────────────────────
|
|
8
|
+
// ── Event translation: the single pi↔SPEC translation point ─────────────────
|
|
25
9
|
//
|
|
26
10
|
// `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
|
|
27
11
|
// guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
|
|
@@ -37,65 +21,6 @@ export function inProcessLease() {
|
|
|
37
21
|
// already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
|
|
38
22
|
// Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
|
|
39
23
|
// entirely (mirrors the §11 "the deeper fix is upstream in pi" pattern).
|
|
40
|
-
/** Clearly-transient network error codes (Node/undici), decisive on their own. */
|
|
41
|
-
const RETRYABLE_CODES = new Set([
|
|
42
|
-
"ECONNRESET",
|
|
43
|
-
"ETIMEDOUT",
|
|
44
|
-
"ENETUNREACH",
|
|
45
|
-
"ENETDOWN",
|
|
46
|
-
"EAI_AGAIN",
|
|
47
|
-
"EPIPE",
|
|
48
|
-
"UND_ERR_CONNECT_TIMEOUT",
|
|
49
|
-
"UND_ERR_SOCKET",
|
|
50
|
-
]);
|
|
51
|
-
/** 429 (rate limit) and 5xx (server) are worth retrying; other statuses are decisive NON-retryable. */
|
|
52
|
-
const statusIsRetryable = (status) => status === 429 || (status >= 500 && status < 600);
|
|
53
|
-
/** Last-resort prose match, used only when no structured status/code is available. */
|
|
54
|
-
const RETRYABLE_MESSAGE = /\b(429|5\d\d|timeout|timed out|rate.?limit|overloaded|ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|socket hang up)\b/i;
|
|
55
|
-
/** A structured status/code decision, or `null` when the signal is absent/undecisive → fall to prose. */
|
|
56
|
-
function retryableFromSignal(signal) {
|
|
57
|
-
if (typeof signal.status === "number")
|
|
58
|
-
return statusIsRetryable(signal.status);
|
|
59
|
-
const { code } = signal;
|
|
60
|
-
if (typeof code === "number")
|
|
61
|
-
return statusIsRetryable(code);
|
|
62
|
-
if (typeof code === "string") {
|
|
63
|
-
if (RETRYABLE_CODES.has(code))
|
|
64
|
-
return true;
|
|
65
|
-
if (/^\d{3}$/.test(code))
|
|
66
|
-
return statusIsRetryable(Number(code)); // a status carried as a string
|
|
67
|
-
}
|
|
68
|
-
return null; // no code, or an unknown one — not decisive on its own
|
|
69
|
-
}
|
|
70
|
-
/** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
|
|
71
|
-
export function classifyRetryable(details, signal) {
|
|
72
|
-
return retryableFromSignal(signal) ?? RETRYABLE_MESSAGE.test(details);
|
|
73
|
-
}
|
|
74
|
-
/** Pull a structured status/code off a thrown error (HTTP status or a network code, incl. its cause). */
|
|
75
|
-
function errorSignal(error) {
|
|
76
|
-
if (!error || typeof error !== "object")
|
|
77
|
-
return {};
|
|
78
|
-
const e = error;
|
|
79
|
-
const status = typeof e.status === "number" ? e.status : typeof e.statusCode === "number" ? e.statusCode : undefined;
|
|
80
|
-
const causeCode = e.cause && typeof e.cause === "object" ? e.cause.code : undefined;
|
|
81
|
-
return { status, code: e.code ?? causeCode };
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Pull the structured error `code` pi records on a failed message's diagnostics. `diagnostics`
|
|
85
|
-
* accumulates across attempts (`appendAssistantMessageDiagnostic`), so the terminal cause is the LAST
|
|
86
|
-
* code-bearing entry — `findLast`, not `find`: an earlier attempt's transient 503 must not classify a
|
|
87
|
-
* terminal 400/auth failure as retryable. (Reverse scan rather than `findLast` — the tsconfig lib is
|
|
88
|
-
* ES2022.)
|
|
89
|
-
*/
|
|
90
|
-
function messageSignal(message) {
|
|
91
|
-
const diagnostics = message.diagnostics ?? [];
|
|
92
|
-
for (let i = diagnostics.length - 1; i >= 0; i--) {
|
|
93
|
-
const code = diagnostics[i]?.error?.code;
|
|
94
|
-
if (code !== undefined)
|
|
95
|
-
return { code };
|
|
96
|
-
}
|
|
97
|
-
return {};
|
|
98
|
-
}
|
|
99
24
|
/**
|
|
100
25
|
* In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
|
|
101
26
|
* the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
|
|
@@ -203,28 +128,6 @@ export function projectAgentEvent(se) {
|
|
|
203
128
|
return null;
|
|
204
129
|
}
|
|
205
130
|
}
|
|
206
|
-
/**
|
|
207
|
-
* Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
|
|
208
|
-
* with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
|
|
209
|
-
* entire failure class (violating SPEC MUST 1).
|
|
210
|
-
*/
|
|
211
|
-
export function toTerminal(message) {
|
|
212
|
-
if (message.stopReason === "aborted") {
|
|
213
|
-
// A deliberate stop (control-plane abort / harness abort), not an error — see {@link ABORTED_CODE}
|
|
214
|
-
// for the consumer contract (design §6).
|
|
215
|
-
const details = message.errorMessage ?? "run aborted";
|
|
216
|
-
return { type: "failed", details, retryable: false, code: ABORTED_CODE };
|
|
217
|
-
}
|
|
218
|
-
if (message.stopReason === "error") {
|
|
219
|
-
const details = message.errorMessage ?? `model stopped: ${message.stopReason}`;
|
|
220
|
-
return { type: "failed", details, retryable: classifyRetryable(details, messageSignal(message)) };
|
|
221
|
-
}
|
|
222
|
-
return { type: "completed" };
|
|
223
|
-
}
|
|
224
|
-
export function errorToTerminal(error) {
|
|
225
|
-
const details = error instanceof Error ? error.message : String(error);
|
|
226
|
-
return { type: "failed", details, retryable: classifyRetryable(details, errorSignal(error)) };
|
|
227
|
-
}
|
|
228
131
|
/** Bind the concrete pi-agent-core Session behind FastAgent's tool-runtime manager port. */
|
|
229
132
|
function toolSessionManager(sessionId, harness) {
|
|
230
133
|
const session = harnessSession(harness);
|
|
@@ -288,100 +191,16 @@ async function maybeCompact(harness, message) {
|
|
|
288
191
|
await harness.compact();
|
|
289
192
|
}
|
|
290
193
|
}
|
|
291
|
-
/**
|
|
292
|
-
* Map prompt images to pi ImageContent, resizing each to model-friendly dimensions/size with pi's
|
|
293
|
-
* Photon resizer (reused from pi-coding-agent, lazy-imported so the common no-image headless path never
|
|
294
|
-
* loads the TUI module graph). A null resize (unresizable / Photon unavailable) keeps the original
|
|
295
|
-
* bytes — the provider then applies its own limit.
|
|
296
|
-
*/
|
|
297
|
-
async function toPiPromptOptions(prompt) {
|
|
298
|
-
if (!prompt.images || prompt.images.length === 0)
|
|
299
|
-
return undefined;
|
|
300
|
-
const { resizeImage } = await import("@earendil-works/pi-coding-agent");
|
|
301
|
-
const images = await Promise.all(prompt.images.map(async (img) => {
|
|
302
|
-
const resized = await resizeImage(Buffer.from(img.data, "base64"), img.mimeType, {
|
|
303
|
-
maxWidth: 1568,
|
|
304
|
-
maxHeight: 1568,
|
|
305
|
-
maxBytes: 5 * 1024 * 1024,
|
|
306
|
-
}).catch(() => null);
|
|
307
|
-
return resized
|
|
308
|
-
? { type: "image", data: resized.data, mimeType: resized.mimeType }
|
|
309
|
-
: { type: "image", data: img.data, mimeType: img.mimeType };
|
|
310
|
-
}));
|
|
311
|
-
return { images };
|
|
312
|
-
}
|
|
313
|
-
// ── §3 EventQueue: push→pull plumbing for pi's two-port shape ────────────────
|
|
314
|
-
//
|
|
315
|
-
// Single-consumer async queue; single-threaded JS means no await interleaves between push and
|
|
316
|
-
// drain, so no locking. Engines that are natively async-iterable would not need it.
|
|
317
|
-
class EventQueue {
|
|
318
|
-
buffer = [];
|
|
319
|
-
wake;
|
|
320
|
-
push(item) {
|
|
321
|
-
this.buffer.push(item);
|
|
322
|
-
const wake = this.wake;
|
|
323
|
-
this.wake = undefined;
|
|
324
|
-
wake?.();
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Yield pushed events in order until `done` settles AND the buffer is drained. The terminal is
|
|
328
|
-
* produced separately (toTerminal); rejections of `done` are swallowed here (the caller awaits
|
|
329
|
-
* `run` itself) to avoid unhandled rejections.
|
|
330
|
-
*/
|
|
331
|
-
async *drainUntil(done) {
|
|
332
|
-
let settled = false;
|
|
333
|
-
const onSettle = () => {
|
|
334
|
-
settled = true;
|
|
335
|
-
const wake = this.wake;
|
|
336
|
-
this.wake = undefined;
|
|
337
|
-
wake?.();
|
|
338
|
-
};
|
|
339
|
-
const finished = done.then(onSettle, onSettle);
|
|
340
|
-
while (true) {
|
|
341
|
-
while (this.buffer.length > 0) {
|
|
342
|
-
yield this.buffer.shift();
|
|
343
|
-
}
|
|
344
|
-
if (settled)
|
|
345
|
-
break;
|
|
346
|
-
await new Promise((resolve) => {
|
|
347
|
-
this.wake = resolve;
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
await finished;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
194
|
/** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
|
|
354
195
|
export function createPiAgentFromHarness(options) {
|
|
355
196
|
const { harnessFactory, lease = inProcessLease(), observer } = options;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// the harness build (the door not yet armed) — abortFirstIterator knocks exactly once, and a
|
|
364
|
-
// knock before prompt() starts would be a no-op on an idle harness (the LATER run would
|
|
365
|
-
// ignore it). So turn consults the latch right after arming and, when the consumer already
|
|
366
|
-
// walked away, never starts the model call at all.
|
|
367
|
-
let externalCancel;
|
|
368
|
-
let cancelled = false;
|
|
369
|
-
const gen = turn(scope, prompt, (cancel) => {
|
|
370
|
-
externalCancel = cancel;
|
|
371
|
-
}, () => cancelled);
|
|
372
|
-
const iterator = abortFirstIterator(gen, () => {
|
|
373
|
-
cancelled = true;
|
|
374
|
-
externalCancel?.();
|
|
375
|
-
});
|
|
376
|
-
return {
|
|
377
|
-
[Symbol.asyncIterator]() {
|
|
378
|
-
return iterator;
|
|
379
|
-
},
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
async function* turn(scope, prompt, onCancelReady,
|
|
383
|
-
/** The consumer's cancel latch (see invoke's wrapper) — checked once at arming. */
|
|
384
|
-
wasCancelled) {
|
|
197
|
+
// The cancellation protocol (SPEC MUST 3) lives in cancellableStream. Both halves matter here:
|
|
198
|
+
// the DOOR settles a generator suspended on a quiet stream (a tool mid-execution) — the local
|
|
199
|
+
// for-await pattern never hit that deadlock, but pull-driven consumers (the SSE handler's eager
|
|
200
|
+
// reads) do — and the LATCH covers the window where the harness is still being built, where a
|
|
201
|
+
// knock would land on an idle harness and the LATER run would ignore it.
|
|
202
|
+
const invoke = (scope, prompt) => cancellableStream((hooks) => turn(scope, prompt, hooks));
|
|
203
|
+
async function* turn(scope, prompt, { onCancelReady, wasCancelled }) {
|
|
385
204
|
const release = lease.tryAcquire(scope.session);
|
|
386
205
|
if (!release) {
|
|
387
206
|
// Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
|
|
@@ -480,7 +299,14 @@ export function createPiAgentFromHarness(options) {
|
|
|
480
299
|
try {
|
|
481
300
|
let harness;
|
|
482
301
|
try {
|
|
483
|
-
|
|
302
|
+
// Scope's lineage extension flows to the store's CREATE path only — an existing session
|
|
303
|
+
// opens exactly as before, whatever the scope names (inheritance is one-time by construction).
|
|
304
|
+
harness = await harnessFactory(scope.session, scope.parentSession === undefined
|
|
305
|
+
? undefined
|
|
306
|
+
: {
|
|
307
|
+
parentSession: scope.parentSession,
|
|
308
|
+
...(scope.branchHints !== undefined ? { branchHints: scope.branchHints } : {}),
|
|
309
|
+
});
|
|
484
310
|
}
|
|
485
311
|
catch (error) {
|
|
486
312
|
// Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
|
|
@@ -497,21 +323,6 @@ export function createPiAgentFromHarness(options) {
|
|
|
497
323
|
onCancelReady(() => {
|
|
498
324
|
void harness.abort().catch(() => { });
|
|
499
325
|
});
|
|
500
|
-
// The consumer cancelled DURING the build (latched — the door above came too late to be
|
|
501
|
-
// knocked): never start the model call; settle as aborted and let the queued return()
|
|
502
|
-
// finish the generator. Same synchronous tick as the arming — a cancel from here on
|
|
503
|
-
// reaches the armed door instead.
|
|
504
|
-
if (wasCancelled()) {
|
|
505
|
-
outcome = { status: "aborted" };
|
|
506
|
-
runSettled = true;
|
|
507
|
-
try {
|
|
508
|
-
await harness.abort(); // teardown — fresh-harness discipline
|
|
509
|
-
}
|
|
510
|
-
catch (error) {
|
|
511
|
-
log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
|
|
512
|
-
}
|
|
513
|
-
return; // → outer finally emits the settlement
|
|
514
|
-
}
|
|
515
326
|
const queue = new EventQueue();
|
|
516
327
|
const unsub = harness.subscribe((pe) => {
|
|
517
328
|
// Summarization retries also warn to server logs: the session `retry_scheduled` event only
|
|
@@ -529,8 +340,38 @@ export function createPiAgentFromHarness(options) {
|
|
|
529
340
|
});
|
|
530
341
|
let completed; // the assistant message of a cleanly completed turn
|
|
531
342
|
try {
|
|
343
|
+
// Preparing the prompt lazy-loads the image pipeline and re-encodes every attachment, so it
|
|
344
|
+
// both takes time and can throw BEFORE any engine work exists to fail — and a throw here
|
|
345
|
+
// would escape the generator and break iteration for the caller, which MUST 2 forbids. It
|
|
346
|
+
// settles the run the same way a setup failure does.
|
|
347
|
+
let opts;
|
|
348
|
+
try {
|
|
349
|
+
opts = await toPiPromptOptions(prompt);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
const terminal = errorToTerminal(error);
|
|
353
|
+
outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
|
|
354
|
+
runSettled = true;
|
|
355
|
+
yield terminal;
|
|
356
|
+
return; // → outer finally emits the settlement
|
|
357
|
+
}
|
|
358
|
+
// The consumer walked away while the harness was built or the prompt prepared (latched — the
|
|
359
|
+
// door armed above only stops a RUNNING harness, so a knock in that window is a no-op the
|
|
360
|
+
// LATER run would ignore): never start the model call. Read AFTER the last await before it,
|
|
361
|
+
// so both windows are covered; settle as aborted and let the queued return() finish the
|
|
362
|
+
// generator.
|
|
363
|
+
if (wasCancelled()) {
|
|
364
|
+
outcome = { status: "aborted" };
|
|
365
|
+
runSettled = true;
|
|
366
|
+
try {
|
|
367
|
+
await harness.abort(); // teardown — fresh-harness discipline
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
|
|
371
|
+
}
|
|
372
|
+
return; // → outer finally emits the settlement
|
|
373
|
+
}
|
|
532
374
|
// Bind current cwd/session/activation capabilities for every FastAgent-defined tool.
|
|
533
|
-
const opts = await toPiPromptOptions(prompt);
|
|
534
375
|
const run = turnContext.run({
|
|
535
376
|
cwd: options.cwd ?? process.cwd(),
|
|
536
377
|
sessionManager: toolSessionManager(scope.session, harness),
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The pi `Models` collection — the single hub that owns BOTH model resolution (provider/modelId
|
|
3
|
-
* lookup) AND auth (per-request credential resolution). fastagent builds one per opener and threads
|
|
4
|
-
* it into the harness alongside the selected `model`; the two must come from the same collection so
|
|
5
|
-
* the model's provider auth is in scope.
|
|
6
|
-
*/
|
|
7
1
|
import { type Api, type Model, type Models, type Provider } from "@earendil-works/pi-ai";
|
|
8
2
|
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
9
3
|
import { type FastagentAuthOptions } from "./auth.ts";
|
|
10
4
|
import { type InteractiveLoginKind } from "./login.ts";
|
|
5
|
+
/** The DEFINITION-LOCAL custom-endpoint file, in pi's own models.json schema (see pi's docs/models.md):
|
|
6
|
+
* declare a self-hosted / gateway endpoint as `{ providers: { <id>: { baseUrl, api, apiKey, models } } }`
|
|
7
|
+
* and select it with a `<id>/<modelId>` model spec. Keys belong in the environment — `apiKey` supports
|
|
8
|
+
* `"$ENV_VAR"` interpolation and `"!command"` — with the NAME listed in `deploy.secrets` so the value
|
|
9
|
+
* travels to the host. Living in the agent dir is the whole point: it is part of the definition, so it
|
|
10
|
+
* is baked into the deployed image. pi's MACHINE-GLOBAL `~/.pi/agent/models.json` stays unread — that
|
|
11
|
+
* one is builder-machine state, and reading it would make an agent work locally and lose its model on
|
|
12
|
+
* deploy. The NAME itself lives in the neutral paths.ts — `dev`'s watcher needs the same one, and a
|
|
13
|
+
* second spelling would let the restart scope drift from what the worker loads. */
|
|
11
14
|
export interface CreatePiModelsOptions extends FastagentAuthOptions {
|
|
12
15
|
/** Credentials file path. Defaults to the global `~/.fastagent/.secrets/auth.json`; the directory opener passes
|
|
13
16
|
* the project-level `<root>/.secrets/auth.json`. */
|
|
@@ -26,15 +29,46 @@ export declare function createPiModels(options?: CreatePiModelsOptions): Models;
|
|
|
26
29
|
/**
|
|
27
30
|
* The `ModelRuntime`-shaped sibling of {@link createPiModels} — the SAME hub semantics (built-in
|
|
28
31
|
* providers + fastagent's credential store at `authPath`) in the type pi's session services require
|
|
29
|
-
* (`createAgentSessionServices({ modelRuntime })`).
|
|
30
|
-
*
|
|
31
|
-
* surface equals serving's.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* (`createAgentSessionServices({ modelRuntime })`). Built-ins PLUS the agent's own
|
|
33
|
+
* {@link AGENT_MODELS_FILE} when `agentDir` is given (a dir-less caller gets built-ins only), and no
|
|
34
|
+
* availability network, so the model surface equals serving's.
|
|
35
|
+
*
|
|
36
|
+
* `ModelRuntime` also takes `Provider` INSTANCES via `registerNativeProvider` (pi 0.83); the
|
|
37
|
+
* declarative file is what this rung wires because it is data that travels with the definition.
|
|
34
38
|
*/
|
|
35
39
|
export declare function createPiModelRuntime(options?: FastagentAuthOptions & {
|
|
36
40
|
authPath?: string;
|
|
41
|
+
/** The agent dir, whose {@link AGENT_MODELS_FILE} declares custom endpoints. Omit for built-ins only. */
|
|
42
|
+
agentDir?: string;
|
|
43
|
+
/** Where the dynamic model-catalog cache goes; defaults to the agent's resolved state root. */
|
|
44
|
+
stateRoot?: string;
|
|
45
|
+
/** Extra providers for the ids the built-ins do not cover — the CODE-shaped sibling of models.json,
|
|
46
|
+
* for what a file cannot express (minting a token per request, a test fake).
|
|
47
|
+
*
|
|
48
|
+
* On an id COLLISION the file wins, not this: upstream installs a native provider as the BASE and
|
|
49
|
+
* composes the models.json entry over it. Right way round — where a deployed agent's traffic goes
|
|
50
|
+
* is a property of the definition, not of the program that embedded it — but it does mean a same-id
|
|
51
|
+
* file entry silently replaces the endpoint injected here. Use a distinct id to keep both. */
|
|
52
|
+
providers?: Provider[];
|
|
37
53
|
}): Promise<ModelRuntime>;
|
|
54
|
+
/**
|
|
55
|
+
* How a model's credential will REACH a deployed agent — the question `deploy` asks, which
|
|
56
|
+
* {@link probeAuthSource} cannot answer: it flattens every models.json endpoint to the display label
|
|
57
|
+
* "configured API key", so a self-hosted endpoint looks credential-less to the deploy gate even when
|
|
58
|
+
* its key is sitting in an env var.
|
|
59
|
+
*
|
|
60
|
+
* - `envVar`: an environment variable backs it, BY NAME — the shape `deploy` already understands, so
|
|
61
|
+
* the value carries as a host secret with no extra declaration from the author.
|
|
62
|
+
* - `inDefinition`: the definition itself carries it (a literal `apiKey`, or a `!command` run on the
|
|
63
|
+
* host). Nothing for `deploy` to carry — and nothing to gate on either, which is the point: the
|
|
64
|
+
* `fastagent login` remedy is meaningless for a provider login cannot serve.
|
|
65
|
+
*
|
|
66
|
+
* Neither set = a stored credential or nothing at all; the existing auth.json / gate paths decide.
|
|
67
|
+
*/
|
|
68
|
+
export declare function modelCredentialCarry(runtime: ModelRuntime, spec: string): {
|
|
69
|
+
envVar?: string;
|
|
70
|
+
inDefinition: boolean;
|
|
71
|
+
};
|
|
38
72
|
/** Per-provider auth status for the first-run model picker: usable now (with the source label), not
|
|
39
73
|
* configured, or configured-but-broken (expired token, refresh failure, corrupt store — kept as DATA
|
|
40
74
|
* so the picker can show it instead of silently dropping the provider). Non-ready states carry the
|