@fastagent-sh/fastagent 0.18.0 → 0.19.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 +25 -7
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +19 -0
- package/dist/atomic-write.js +48 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-service.d.ts +54 -0
- package/dist/channels/agentcore-service.js +111 -0
- package/dist/channels/agentcore.d.ts +12 -2
- package/dist/channels/agentcore.js +6 -4
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +16 -6
- package/dist/channels/control.js +106 -4
- package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
- package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.js +1 -1
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +7 -7
- package/dist/channels/feishu/invoke-turn.d.ts +1 -1
- package/dist/channels/feishu/invoke-turn.js +3 -4
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +1 -109
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/{state.js → kit/state.js} +4 -7
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
- package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +8 -5
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/slack-api.js +1 -1
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +8 -8
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +1 -1
- package/dist/channels/telegram/parse.js +2 -2
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/telegram.d.ts +1 -1
- package/dist/channels/telegram/telegram.js +3 -3
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/commands/add.js +1 -1
- package/dist/cli/commands/dev.js +25 -13
- package/dist/cli/commands/fire.js +1 -1
- package/dist/cli/commands/info.js +7 -4
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +63 -94
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +18 -57
- package/dist/cli/serve.js +57 -243
- package/dist/cli/shared.d.ts +1 -1
- package/dist/cli/shared.js +2 -2
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/preflight.js +1 -1
- package/dist/dev-supervisor.js +9 -3
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +314 -0
- package/dist/engines/pi/config.d.ts +4 -4
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +56 -43
- package/dist/engines/pi/create.js +141 -71
- package/dist/engines/pi/definition.d.ts +41 -7
- package/dist/engines/pi/definition.js +115 -11
- package/dist/engines/pi/invoke-session.d.ts +22 -19
- package/dist/engines/pi/invoke-session.js +286 -100
- package/dist/engines/pi/login.js +7 -4
- package/dist/engines/pi/models.d.ts +14 -0
- package/dist/engines/pi/models.js +9 -1
- package/dist/engines/pi/open.d.ts +9 -6
- package/dist/engines/pi/open.js +20 -10
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +19 -0
- package/dist/engines/pi/session-builder.js +84 -88
- package/dist/engines/pi/session-control.d.ts +10 -9
- package/dist/engines/pi/session-control.js +114 -77
- package/dist/engines/pi/session-inheritance.d.ts +44 -0
- package/dist/engines/pi/session-inheritance.js +242 -0
- package/dist/engines/pi/session-settings.d.ts +14 -2
- package/dist/engines/pi/session-settings.js +18 -0
- package/dist/engines/pi/session-store.d.ts +70 -0
- package/dist/engines/pi/session-store.js +313 -0
- package/dist/engines/pi/tool-context.d.ts +18 -8
- package/dist/engines/pi/tool-context.js +23 -1
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +1 -1
- package/dist/engines/pi/turn-kit.d.ts +32 -6
- package/dist/engines/pi/turn-kit.js +28 -1
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +10 -0
- package/dist/pi.d.ts +14 -8
- package/dist/pi.js +6 -5
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +1 -1
- package/dist/service.d.ts +158 -0
- package/dist/service.js +354 -0
- package/dist/session-remote.d.ts +1 -1
- package/dist/session.d.ts +4 -2
- package/dist/session.js +1 -1
- package/package.json +14 -5
- package/dist/engines/pi/harness.d.ts +0 -115
- package/dist/engines/pi/harness.js +0 -154
- package/dist/engines/pi/invoke.d.ts +0 -60
- package/dist/engines/pi/invoke.js +0 -459
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -60
- package/dist/engines/pi/sessions.js +0 -464
- package/dist/host/node.js +0 -52
- /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
- /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
- /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
|
@@ -1,459 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
|
|
3
|
-
import { cancellableStream } from "../../collect.js";
|
|
4
|
-
import { log } from "../../log.js";
|
|
5
|
-
import { TOOL_ACTIVATION_ENTRY, harnessSession } from "./harness.js";
|
|
6
|
-
import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
|
|
7
|
-
import { additiveActivation, turnContext } from "./tool-context.js";
|
|
8
|
-
// ── Event translation: the single pi↔SPEC translation point ─────────────────
|
|
9
|
-
//
|
|
10
|
-
// `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
|
|
11
|
-
// guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
|
|
12
|
-
// is actually available differs by path, and the two are NOT symmetric:
|
|
13
|
-
// - thrown error (errorToTerminal): an HTTP `.status`/`.statusCode` AND a network `.code` (incl.
|
|
14
|
-
// `.cause.code`) — this is where a numeric status genuinely drives the decision.
|
|
15
|
-
// - failed message (toTerminal): ONLY `diagnostics[].error.code`. pi's `DiagnosticErrorInfo` carries
|
|
16
|
-
// a `code` (a network code, or a status delivered as a code), with no separate HTTP-status field —
|
|
17
|
-
// so a message whose provider `code` is a string label (e.g. "rate_limit_exceeded") is not
|
|
18
|
-
// decisive here and falls to prose.
|
|
19
|
-
// The prose fallback is bounded, not a cop-out: pi-ai already ran its own status-code-based client
|
|
20
|
-
// retries (harness.ts PROVIDER_MAX_RETRIES) before surfacing, so an error that reaches this point has
|
|
21
|
-
// already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
|
|
22
|
-
// Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
|
|
23
|
-
// entirely (mirrors the §11 "the deeper fix is upstream in pi" pattern).
|
|
24
|
-
/**
|
|
25
|
-
* In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
|
|
26
|
-
* the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
|
|
27
|
-
* translation plus one projection, never two parallel translations). pi events with no session
|
|
28
|
-
* vocabulary yet (turn_start, agent_start, …) are dropped.
|
|
29
|
-
*/
|
|
30
|
-
export function toSessionEvent(pe, runId) {
|
|
31
|
-
const at = Date.now();
|
|
32
|
-
switch (pe.type) {
|
|
33
|
-
case "queue_update":
|
|
34
|
-
return {
|
|
35
|
-
type: "queue_changed",
|
|
36
|
-
timestamp: at,
|
|
37
|
-
runId,
|
|
38
|
-
data: { steering: pe.steer.length, followUp: pe.followUp.length },
|
|
39
|
-
};
|
|
40
|
-
case "message_start":
|
|
41
|
-
// Assistant streaming only — a user/toolResult message is not a live message boundary.
|
|
42
|
-
if (pe.message.role !== "assistant")
|
|
43
|
-
return null;
|
|
44
|
-
return { type: "message_started", timestamp: at, runId, data: {} };
|
|
45
|
-
case "message_update": {
|
|
46
|
-
const ev = pe.assistantMessageEvent;
|
|
47
|
-
if (ev.type === "text_delta") {
|
|
48
|
-
return { type: "message_delta", timestamp: at, runId, data: { channel: "text", delta: ev.delta } };
|
|
49
|
-
}
|
|
50
|
-
if (ev.type === "thinking_delta") {
|
|
51
|
-
return { type: "message_delta", timestamp: at, runId, data: { channel: "thinking", delta: ev.delta } };
|
|
52
|
-
}
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
case "message_end":
|
|
56
|
-
if (pe.message.role !== "assistant")
|
|
57
|
-
return null;
|
|
58
|
-
return { type: "message_finished", timestamp: at, runId, data: {} };
|
|
59
|
-
case "tool_execution_start":
|
|
60
|
-
return {
|
|
61
|
-
type: "tool_started",
|
|
62
|
-
timestamp: at,
|
|
63
|
-
runId,
|
|
64
|
-
data: { id: pe.toolCallId, name: pe.toolName, args: pe.args },
|
|
65
|
-
};
|
|
66
|
-
case "tool_execution_update":
|
|
67
|
-
return {
|
|
68
|
-
type: "tool_progress",
|
|
69
|
-
timestamp: at,
|
|
70
|
-
runId,
|
|
71
|
-
data: { id: pe.toolCallId, name: pe.toolName, partialResult: pe.partialResult },
|
|
72
|
-
};
|
|
73
|
-
case "tool_execution_end":
|
|
74
|
-
return {
|
|
75
|
-
type: "tool_finished",
|
|
76
|
-
timestamp: at,
|
|
77
|
-
runId,
|
|
78
|
-
data: { id: pe.toolCallId, isError: pe.isError, content: pe.result },
|
|
79
|
-
};
|
|
80
|
-
case "retry_scheduled": {
|
|
81
|
-
// Summarization retry backoff (auto-compaction / branch summary, pi ≥0.81.1) — without it,
|
|
82
|
-
// up to ~14s of backoff at the turn's tail reads as a hang. `retry_attempt_start`/
|
|
83
|
-
// `retry_finished` stay dropped: they carry no outcome, and the next event is the closure.
|
|
84
|
-
// Typed against the vocabulary (as is the second construction site, session-control's
|
|
85
|
-
// manual-compact callback) so a payload change breaks both at compile time.
|
|
86
|
-
const event = {
|
|
87
|
-
type: "retry_scheduled",
|
|
88
|
-
timestamp: at,
|
|
89
|
-
runId,
|
|
90
|
-
data: {
|
|
91
|
-
operation: pe.operation,
|
|
92
|
-
attempt: pe.attempt,
|
|
93
|
-
maxAttempts: pe.maxAttempts,
|
|
94
|
-
delayMs: pe.delayMs,
|
|
95
|
-
error: pe.errorMessage,
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
return event;
|
|
99
|
-
}
|
|
100
|
-
default:
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
/** The SPEC projection of the rich stream. Events with no `AgentEvent` counterpart (progress,
|
|
105
|
-
* message boundaries, run boundaries) project to null — the invoke terminal is produced from the
|
|
106
|
-
* resolved message ({@link toTerminal}), not from `run_settled`. */
|
|
107
|
-
export function projectAgentEvent(se) {
|
|
108
|
-
switch (se.type) {
|
|
109
|
-
case "message_delta": {
|
|
110
|
-
const d = se.data;
|
|
111
|
-
return d.channel === "text" ? { type: "text", delta: d.delta } : { type: "thinking", delta: d.delta };
|
|
112
|
-
}
|
|
113
|
-
case "tool_started": {
|
|
114
|
-
const d = se.data;
|
|
115
|
-
return { type: "tool_started", id: d.id, name: d.name, args: d.args };
|
|
116
|
-
}
|
|
117
|
-
case "tool_finished": {
|
|
118
|
-
const d = se.data;
|
|
119
|
-
return { type: "tool_ended", id: d.id, isError: d.isError, content: d.content };
|
|
120
|
-
}
|
|
121
|
-
case "retry_scheduled": {
|
|
122
|
-
// `operation` (compaction | branch_summary) stays session-plane vocabulary — a turn renderer
|
|
123
|
-
// only needs "transient failure, retrying"; the engine detail lives in the control plane.
|
|
124
|
-
const d = se.data;
|
|
125
|
-
return { type: "retrying", attempt: d.attempt, maxAttempts: d.maxAttempts, delayMs: d.delayMs, reason: d.error };
|
|
126
|
-
}
|
|
127
|
-
default:
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
/** Bind the concrete pi-agent-core Session behind FastAgent's tool-runtime manager port. */
|
|
132
|
-
function toolSessionManager(sessionId, harness) {
|
|
133
|
-
const session = harnessSession(harness);
|
|
134
|
-
if (!session)
|
|
135
|
-
return undefined;
|
|
136
|
-
return {
|
|
137
|
-
getSessionId: () => sessionId,
|
|
138
|
-
async getHeader() {
|
|
139
|
-
const metadata = await session.getMetadata();
|
|
140
|
-
return { id: sessionId, timestamp: metadata.createdAt };
|
|
141
|
-
},
|
|
142
|
-
getBranch: () => session.getBranch(),
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* The turn's {@link ToolActivation} over the live harness. `activate` is additive and filters to the
|
|
147
|
-
* registered names first — pi's `setActiveTools` THROWS on unknown names, and a loader must get a
|
|
148
|
-
* usable "nothing new" answer, not an exception. pi persists the change in the session, so the
|
|
149
|
-
* per-invoke restore (harness.ts) carries it into later turns.
|
|
150
|
-
*/
|
|
151
|
-
function toolActivation(harness) {
|
|
152
|
-
// Serialize activations per turn: "who activated first" must be decided HERE, not by whether pi's
|
|
153
|
-
// setActiveTools happens to mutate before its first await — parallel tool calls in one batch race
|
|
154
|
-
// their activate() calls, and the addedToolNames load points must not double-stamp.
|
|
155
|
-
let chain = Promise.resolve([]);
|
|
156
|
-
return {
|
|
157
|
-
active: () => harness.getActiveTools().map((t) => t.name),
|
|
158
|
-
registered: () => harness.getTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
|
|
159
|
-
activate(names) {
|
|
160
|
-
const run = async () => {
|
|
161
|
-
const current = harness.getActiveTools().map((t) => t.name);
|
|
162
|
-
const added = additiveActivation(harness.getTools().map((t) => t.name), current, names);
|
|
163
|
-
if (added.length > 0) {
|
|
164
|
-
await harness.setActiveTools([...current, ...added]);
|
|
165
|
-
// Persist the DELTA in a dedicated entry — what the per-invoke resolve (harness.ts) reads.
|
|
166
|
-
// pi's own active_tools_change record is a full snapshot and is deliberately ignored there.
|
|
167
|
-
// Absent session (a harness built outside piHarnessFactory): in-turn activation still works,
|
|
168
|
-
// it just isn't durable — the factory owns persistence.
|
|
169
|
-
await harnessSession(harness)?.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
|
|
170
|
-
}
|
|
171
|
-
return added;
|
|
172
|
-
};
|
|
173
|
-
const result = chain.then(run, run); // run after the predecessor settles, success or failure
|
|
174
|
-
chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
|
|
175
|
-
return result;
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* After a successful turn, compact the session if its context has grown past pi's threshold — a long
|
|
181
|
-
* shared (group) or 1:1 conversation otherwise overflows the model's window. pi owns the mechanism
|
|
182
|
-
* (`harness.compact()` writes a summary entry into the session, so the next reopen is compacted); the
|
|
183
|
-
* bare harness does NOT auto-trigger it, so fastagent checks `shouldCompact` here and fires it. The
|
|
184
|
-
* context size is the provider's own count from the turn's assistant message (`usage`).
|
|
185
|
-
*/
|
|
186
|
-
async function maybeCompact(harness, message) {
|
|
187
|
-
const contextWindow = harness.getModel().contextWindow;
|
|
188
|
-
if (!contextWindow)
|
|
189
|
-
return;
|
|
190
|
-
if (shouldCompact(calculateContextTokens(message.usage), contextWindow, DEFAULT_COMPACTION_SETTINGS)) {
|
|
191
|
-
await harness.compact();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
/** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
|
|
195
|
-
export function createPiAgentFromHarness(options) {
|
|
196
|
-
const { harnessFactory, lease = inProcessLease(), observer } = options;
|
|
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 }) {
|
|
204
|
-
const release = lease.tryAcquire(scope.session);
|
|
205
|
-
if (!release) {
|
|
206
|
-
// Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
|
|
207
|
-
yield {
|
|
208
|
-
type: "failed",
|
|
209
|
-
details: "session busy: a turn is already in flight for this session",
|
|
210
|
-
retryable: true,
|
|
211
|
-
code: SESSION_BUSY_CODE,
|
|
212
|
-
};
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
// The run exists from here: one run_started, exactly one run_settled. Terminal points only
|
|
216
|
-
// RECORD the outcome; the settlement event is emitted in the outer finally, right before
|
|
217
|
-
// release() — so the observation plane's "running" window equals the lease window (state()
|
|
218
|
-
// must never say idle while a new invoke would still be rejected session_busy), and the
|
|
219
|
-
// post-terminal auto-compaction is naturally inside the run. A run with no recorded outcome
|
|
220
|
-
// was cancelled by the caller (SPEC: cancellation has no terminal event) → aborted.
|
|
221
|
-
const runId = crypto.randomUUID();
|
|
222
|
-
let outcome;
|
|
223
|
-
const observe = (event, run) => {
|
|
224
|
-
if (!event || !observer)
|
|
225
|
-
return;
|
|
226
|
-
try {
|
|
227
|
-
observer(scope.session, event, run);
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
// The observation plane must never break the data plane; a broken hub is its own problem.
|
|
231
|
-
log.warn(`[fastagent] session observer threw (event ${event.type}): ${String(error)}`);
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
// run_started must be observed before the (awaited) harness build so no early event outruns
|
|
235
|
-
// registration — so the controls AWAIT the build instead of erroring on the assembling window:
|
|
236
|
-
// a dispatch that races the build simply queues on the freshly built harness. A setup failure
|
|
237
|
-
// rejects the gate (and the run settles failed); the guard keeps an undispatched rejection from
|
|
238
|
-
// becoming an unhandled-rejection crash.
|
|
239
|
-
let harnessReady;
|
|
240
|
-
let harnessFailed;
|
|
241
|
-
const harnessGate = new Promise((resolve, reject) => {
|
|
242
|
-
harnessReady = resolve;
|
|
243
|
-
harnessFailed = reject;
|
|
244
|
-
});
|
|
245
|
-
harnessGate.catch(() => { }); // observed via controls only when a dispatch actually happens
|
|
246
|
-
// Aborted classification has two attribution sources, either suffices: pi's own
|
|
247
|
-
// stopReason:"aborted" (toTerminal), and control-plane INTENT — needed because providers do
|
|
248
|
-
// not uniformly attribute an aborted stream (verified empirically: the faux path surfaces a
|
|
249
|
-
// plain error). Intent = "an abort() succeeded, OR one was still in flight when the terminal
|
|
250
|
-
// arrived" (the harness error often lands before abort() resolves). A rejected abort that
|
|
251
|
-
// RETURNED before the terminal counts as nothing — no rollback dance, no interleaving hazard.
|
|
252
|
-
// GUARANTEE BOUNDARY: an abort still in flight that ultimately rejects can classify a
|
|
253
|
-
// concurrent real error as aborted — narrow, and non-lossy: the settlement carries
|
|
254
|
-
// `error.message` either way.
|
|
255
|
-
let abortsInFlight = 0;
|
|
256
|
-
let abortSucceeded = false;
|
|
257
|
-
// Stale-controls guard: after settlement pi's steer()/followUp()/abort() would still resolve
|
|
258
|
-
// (they queue / no-op on the to-be-discarded harness) — a silent acceptance of a command that
|
|
259
|
-
// can never take effect. The flag flips at THREE points, earliest wins: (1) the moment the
|
|
260
|
-
// run's terminal is determined (the main window — before the consumer-paced `yield terminal`
|
|
261
|
-
// and auto-compaction), (2) the setup-failure path, (3) the outer finally as the backstop for
|
|
262
|
-
// caller cancellation. A post-settle call throws and the dispatcher maps it to
|
|
263
|
-
// `run_command_failed`.
|
|
264
|
-
let runSettled = false;
|
|
265
|
-
const settledError = () => new Error("run already settled; the command cannot take effect");
|
|
266
|
-
// The settled check and the harness call MUST share one synchronous block (no await between):
|
|
267
|
-
// pi enqueues/aborts synchronously at method entry, so check-then-call in the same tick truly
|
|
268
|
-
// closes the race — a check behind its own await boundary would only shrink it.
|
|
269
|
-
const controls = {
|
|
270
|
-
async steer(p) {
|
|
271
|
-
const opts = await toPiPromptOptions(p);
|
|
272
|
-
const harness = await harnessGate;
|
|
273
|
-
if (runSettled)
|
|
274
|
-
throw settledError();
|
|
275
|
-
await harness.steer(p.text, opts);
|
|
276
|
-
},
|
|
277
|
-
async followUp(p) {
|
|
278
|
-
const opts = await toPiPromptOptions(p);
|
|
279
|
-
const harness = await harnessGate;
|
|
280
|
-
if (runSettled)
|
|
281
|
-
throw settledError();
|
|
282
|
-
await harness.followUp(p.text, opts);
|
|
283
|
-
},
|
|
284
|
-
async abort() {
|
|
285
|
-
const harness = await harnessGate;
|
|
286
|
-
if (runSettled)
|
|
287
|
-
throw settledError();
|
|
288
|
-
abortsInFlight++;
|
|
289
|
-
try {
|
|
290
|
-
await harness.abort();
|
|
291
|
-
abortSucceeded = true;
|
|
292
|
-
}
|
|
293
|
-
finally {
|
|
294
|
-
abortsInFlight--;
|
|
295
|
-
}
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
observe({ type: "run_started", timestamp: Date.now(), runId, data: {} }, controls);
|
|
299
|
-
try {
|
|
300
|
-
let harness;
|
|
301
|
-
try {
|
|
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
|
-
});
|
|
310
|
-
}
|
|
311
|
-
catch (error) {
|
|
312
|
-
// Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
|
|
313
|
-
harnessFailed(error); // a pending dispatch learns the run cannot take commands
|
|
314
|
-
const terminal = errorToTerminal(error);
|
|
315
|
-
outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
|
|
316
|
-
runSettled = true; // commands can no longer take effect — reject stale controls from here on
|
|
317
|
-
yield terminal;
|
|
318
|
-
return; // → outer finally emits the settlement
|
|
319
|
-
}
|
|
320
|
-
harnessReady(harness);
|
|
321
|
-
// Arm the cancellation door (see invoke's wrapper): aborting the harness settles the run,
|
|
322
|
-
// releasing any await the generator is parked on so a queued return() can reach the finally.
|
|
323
|
-
onCancelReady(() => {
|
|
324
|
-
void harness.abort().catch(() => { });
|
|
325
|
-
});
|
|
326
|
-
const queue = new EventQueue();
|
|
327
|
-
const unsub = harness.subscribe((pe) => {
|
|
328
|
-
// Summarization retries also warn to server logs: the session `retry_scheduled` event only
|
|
329
|
-
// reaches attached observers, and an operator tailing logs must see the backoff too.
|
|
330
|
-
if (pe.type === "retry_scheduled") {
|
|
331
|
-
log.warn(`[fastagent] ${pe.operation} retry ${pe.attempt}/${pe.maxAttempts} in ${pe.delayMs}ms (session ${scope.session}): ${pe.errorMessage}`);
|
|
332
|
-
}
|
|
333
|
-
const rich = toSessionEvent(pe, runId);
|
|
334
|
-
if (!rich)
|
|
335
|
-
return;
|
|
336
|
-
observe(rich);
|
|
337
|
-
const event = projectAgentEvent(rich);
|
|
338
|
-
if (event)
|
|
339
|
-
queue.push(event);
|
|
340
|
-
});
|
|
341
|
-
let completed; // the assistant message of a cleanly completed turn
|
|
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
|
-
}
|
|
374
|
-
// Bind current cwd/session/activation capabilities for every FastAgent-defined tool.
|
|
375
|
-
const run = turnContext.run({
|
|
376
|
-
cwd: options.cwd ?? process.cwd(),
|
|
377
|
-
sessionManager: toolSessionManager(scope.session, harness),
|
|
378
|
-
tools: toolActivation(harness),
|
|
379
|
-
}, () => harness.prompt(prompt.text, opts));
|
|
380
|
-
yield* queue.drainUntil(run);
|
|
381
|
-
let terminal;
|
|
382
|
-
try {
|
|
383
|
-
const message = await run;
|
|
384
|
-
terminal = toTerminal(message);
|
|
385
|
-
if (terminal.type === "completed")
|
|
386
|
-
completed = message;
|
|
387
|
-
}
|
|
388
|
-
catch (error) {
|
|
389
|
-
terminal = errorToTerminal(error);
|
|
390
|
-
}
|
|
391
|
-
if ((abortSucceeded || abortsInFlight > 0) && terminal.type === "failed") {
|
|
392
|
-
terminal = { type: "failed", details: terminal.details, retryable: false, code: ABORTED_CODE };
|
|
393
|
-
}
|
|
394
|
-
if (terminal.type === "completed")
|
|
395
|
-
outcome = { status: "completed" };
|
|
396
|
-
else if (terminal.type === "failed") {
|
|
397
|
-
outcome =
|
|
398
|
-
terminal.code === ABORTED_CODE
|
|
399
|
-
? // Carry the detail: an independent real error that raced an accepted abort must stay
|
|
400
|
-
// diagnosable in the settlement (audit consumers read run_settled, not the invoke
|
|
401
|
-
// stream) — aborted classifies the run, the message preserves what actually stopped it.
|
|
402
|
-
{ status: "aborted", error: { message: terminal.details, retryable: false } }
|
|
403
|
-
: {
|
|
404
|
-
status: "failed",
|
|
405
|
-
error: { code: terminal.code, message: terminal.details, retryable: terminal.retryable },
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
// Commands become ineffective the moment the run resolved — NOT at the outer finally, which
|
|
409
|
-
// sits behind `yield terminal` (a consumer-paced suspension) and auto-compaction. Flipping
|
|
410
|
-
// here closes the silent-drop window for steer/follow_up dispatched in that gap; the
|
|
411
|
-
// outer-finally flip remains as the backstop for caller cancellation.
|
|
412
|
-
runSettled = true;
|
|
413
|
-
yield terminal;
|
|
414
|
-
}
|
|
415
|
-
finally {
|
|
416
|
-
// After a successful turn, keep the session under the model's context window (a long shared group
|
|
417
|
-
// or 1:1 conversation would otherwise overflow). Runs HERE — before teardown (it uses the harness)
|
|
418
|
-
// and BEFORE the lease release below, and is awaited via the generator's return(), so the next
|
|
419
|
-
// turn for this session waits and never reopens mid-compaction. That await rides the consumer's
|
|
420
|
-
// iteration: a STREAMING consumer (e.g. telegram) already sent the reply on the terminal before
|
|
421
|
-
// returning, so compaction — rare, only over threshold — does not delay it; a `collect`-style
|
|
422
|
-
// consumer returns the reply FROM the loop, so it waits for the (occasional) compaction. Non-fatal:
|
|
423
|
-
// a failed compaction leaves the (still-valid) session for the next turn to retry.
|
|
424
|
-
if (completed) {
|
|
425
|
-
try {
|
|
426
|
-
await maybeCompact(harness, completed);
|
|
427
|
-
}
|
|
428
|
-
catch (error) {
|
|
429
|
-
log.warn(`[fastagent] auto-compaction failed during cleanup: ${String(error)}`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
// Cleanup MUST NOT throw after the terminal was yielded — that would make an already-closed
|
|
433
|
-
// event stream throw on iteration (violating SPEC MUST 2 / MUST 3). Contain it, but surface it
|
|
434
|
-
// (a cleanup failure is abnormal).
|
|
435
|
-
try {
|
|
436
|
-
unsub();
|
|
437
|
-
}
|
|
438
|
-
catch (error) {
|
|
439
|
-
log.warn(`[fastagent] harness unsubscribe failed during cleanup: ${String(error)}`);
|
|
440
|
-
}
|
|
441
|
-
try {
|
|
442
|
-
await harness.abort();
|
|
443
|
-
}
|
|
444
|
-
catch (error) {
|
|
445
|
-
log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
finally {
|
|
450
|
-
// Exactly-one settlement, after ALL run work (incl. auto-compaction) and immediately before
|
|
451
|
-
// the lease releases — see the outcome note above. The stale-controls flag flips FIRST so a
|
|
452
|
-
// dispatch racing this settlement is rejected instead of silently accepted.
|
|
453
|
-
runSettled = true;
|
|
454
|
-
observe({ type: "run_settled", timestamp: Date.now(), runId, data: outcome ?? { status: "aborted" } });
|
|
455
|
-
release(); // after cleanup, so the next invoke for this session can enter
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
return { invoke };
|
|
459
|
-
}
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import type { ReadImageProcessor } from "@earendil-works/pi-agent-core";
|
|
2
|
-
/** The `read` tool's image processor. Matches pi-coding-agent's messages verbatim: they reach the model
|
|
3
|
-
* as tool output, so a reworded one is a different prompt, not a different implementation detail. */
|
|
4
|
-
export declare const readImageProcessor: ReadImageProcessor;
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The image pipeline pi's `read` tool needs: normalize an unsupported format to PNG, resize below the
|
|
3
|
-
* inline limit, and hand back the hints that tell the model what it is looking at.
|
|
4
|
-
*
|
|
5
|
-
* pi-agent-core's `createReadTool` takes this as an INJECTED processor and does nothing without one —
|
|
6
|
-
* unlike pi-coding-agent's, which wires its private `processImage` internally. That function is not
|
|
7
|
-
* exported (nor reachable: the package's `exports` map has no deep paths), so this rebuilds it from the
|
|
8
|
-
* two halves that ARE public, `convertToPng` and `resizeImage`/`formatDimensionNote`.
|
|
9
|
-
*
|
|
10
|
-
* It is upstream logic restated, which is a real cost — without it `read` on a screenshot sends the raw
|
|
11
|
-
* bytes (measured: 7.48 MB of base64 where pi-coding-agent sends 3.48 MB, and no dimension note for the
|
|
12
|
-
* model's coordinate math), and a bmp is dropped entirely while the tool's own description still
|
|
13
|
-
* advertises it. test/tools-parity.test.ts compares this against pi-coding-agent's real `read` on both
|
|
14
|
-
* paths, so upstream changing the pipeline surfaces as a failing test rather than as drift.
|
|
15
|
-
*/
|
|
16
|
-
import { convertToPng, formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
/** Formats a provider takes inline as-is; everything else has to become a PNG first. */
|
|
18
|
-
const INLINE_MIME = {
|
|
19
|
-
"image/png": "image/png",
|
|
20
|
-
"image/jpeg": "image/jpeg",
|
|
21
|
-
"image/jpg": "image/jpeg",
|
|
22
|
-
"image/gif": "image/gif",
|
|
23
|
-
"image/webp": "image/webp",
|
|
24
|
-
};
|
|
25
|
-
/** The `read` tool's image processor. Matches pi-coding-agent's messages verbatim: they reach the model
|
|
26
|
-
* as tool output, so a reworded one is a different prompt, not a different implementation detail. */
|
|
27
|
-
export const readImageProcessor = async (bytes, mimeType, options) => {
|
|
28
|
-
const base = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
29
|
-
const inline = INLINE_MIME[base];
|
|
30
|
-
let normalized;
|
|
31
|
-
if (inline) {
|
|
32
|
-
normalized = { bytes, mimeType: inline };
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
const png = await convertToPng(Buffer.from(bytes).toString("base64"), base);
|
|
36
|
-
if (!png)
|
|
37
|
-
return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" };
|
|
38
|
-
normalized = { bytes: Buffer.from(png.data, "base64"), mimeType: png.mimeType, convertedFrom: base };
|
|
39
|
-
}
|
|
40
|
-
const hints = [];
|
|
41
|
-
const converted = (to) => normalized.convertedFrom && normalized.convertedFrom !== to
|
|
42
|
-
? `[Image converted from ${normalized.convertedFrom} to ${to}.]`
|
|
43
|
-
: undefined;
|
|
44
|
-
if (!options.autoResizeImages) {
|
|
45
|
-
const hint = converted(normalized.mimeType);
|
|
46
|
-
if (hint)
|
|
47
|
-
hints.push(hint);
|
|
48
|
-
return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints };
|
|
49
|
-
}
|
|
50
|
-
const resized = await resizeImage(normalized.bytes, normalized.mimeType);
|
|
51
|
-
if (!resized)
|
|
52
|
-
return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" };
|
|
53
|
-
const hint = converted(resized.mimeType);
|
|
54
|
-
if (hint)
|
|
55
|
-
hints.push(hint);
|
|
56
|
-
// The scale factor the model needs to map coordinates back onto the original — dropping it is what
|
|
57
|
-
// makes a resized screenshot unusable for anything positional.
|
|
58
|
-
const note = formatDimensionNote(resized);
|
|
59
|
-
if (note)
|
|
60
|
-
hints.push(note);
|
|
61
|
-
return { ok: true, data: resized.data, mimeType: resized.mimeType, hints };
|
|
62
|
-
};
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type { Session, SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
|
2
|
-
/**
|
|
3
|
-
* Where a NEW session starts from, when it names a parent (participant-model.md §5: "a thread starts
|
|
4
|
-
* from what the room knew"). Read only on the create path — an EXISTING session ignores it entirely,
|
|
5
|
-
* which is what makes inheritance one-time by construction: no marker to persist, no decision to
|
|
6
|
-
* retry per turn; the session existing IS the record that the decision was taken.
|
|
7
|
-
*/
|
|
8
|
-
export interface SessionInheritance {
|
|
9
|
-
/** The session to fork from. Missing or unreadable → the new session starts empty, with a warn —
|
|
10
|
-
* context is not the ask, and losing it must not cost the turn. */
|
|
11
|
-
parentSession: string;
|
|
12
|
-
/** Opaque markers that MAY locate the branch point on the parent's active path (searched in
|
|
13
|
-
* message content, first hit wins, most recent occurrence). No match → the parent's present. */
|
|
14
|
-
branchHints?: string[];
|
|
15
|
-
}
|
|
16
|
-
/** What fastagent needs from a session backend: open-or-create by opaque id. */
|
|
17
|
-
export interface PiSessionStore {
|
|
18
|
-
openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<Session>;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
|
|
22
|
-
* `undefined`, never creates one — sessions are the data plane's monopoly. Two consumers:
|
|
23
|
-
* - the OBSERVATION plane (`state()`/`entries()`), strictly read-only (design §16 invariant 4);
|
|
24
|
-
* - the control plane's BOUNDARY writers (`set_model`/`set_thinking` append override records to the
|
|
25
|
-
* returned handle; `navigate` moves its leaf) after an existence check, under the run lease.
|
|
26
|
-
* `openIfExists` skips the open-time crash reconciliation (that appends repair entries — a write
|
|
27
|
-
* the observation plane must not perform). The boundary writers are safe WITHOUT it for two
|
|
28
|
-
* different reasons: an override record is not a message, so it cannot create or pair with a
|
|
29
|
-
* dangling tool_use; a `navigate` writes no message either, but it CAN expose one — parking the
|
|
30
|
-
* leaf on an assistant entry whose tool results are now off-path is the dangling-pair state
|
|
31
|
-
* {@link reconcileInterruptedToolCalls} exists for. That is repaired at the next `openOrCreate`,
|
|
32
|
-
* which repairs AT THE LEAF — exactly where a move puts it.
|
|
33
|
-
*
|
|
34
|
-
* Writing MESSAGE-class records through this handle would bypass that repair: use `openOrCreate`
|
|
35
|
-
* for anything that enters the transcript.
|
|
36
|
-
*/
|
|
37
|
-
export interface PiSessionReader {
|
|
38
|
-
openIfExists(sessionId: string): Promise<Session | undefined>;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* The entries on the session's ACTIVE path, root→leaf — what every last-wins read must walk.
|
|
42
|
-
* `getEntries()` is the whole TREE: once `navigate` can move the leaf, the journal still carries
|
|
43
|
-
* the abandoned branch, and reading it flat would run the session on a setting it moved away from.
|
|
44
|
-
* Deliberately NOT `Session.getBranch()`: that walk is bounded by the last compaction's retained
|
|
45
|
-
* window, which is the right bound for MODEL CONTEXT and the wrong one for settings — an override
|
|
46
|
-
* recorded before a compaction is a preference, and it still governs the session after one.
|
|
47
|
-
*/
|
|
48
|
-
export declare function activePathEntries(session: Session): Promise<SessionTreeEntry[]>;
|
|
49
|
-
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
50
|
-
export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader;
|
|
51
|
-
/**
|
|
52
|
-
* Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
|
|
53
|
-
* `cwd` is recorded in session metadata; defaults to process.cwd().
|
|
54
|
-
*/
|
|
55
|
-
export declare function jsonlSessionStore(options: {
|
|
56
|
-
dir: string;
|
|
57
|
-
cwd?: string;
|
|
58
|
-
/** Inheritance guard override (tests): parent journals above this are not forked. Default 32 MiB. */
|
|
59
|
-
forkMaxBytes?: number;
|
|
60
|
-
}): PiSessionStore & PiSessionReader;
|