@beeos-ai/device-agent 0.5.1 → 0.6.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/.pi/extensions/android-tools.ts +24 -3
- package/dist/app/in-flight-turn.d.ts +120 -0
- package/dist/app/in-flight-turn.js +158 -0
- package/dist/app/in-flight-turn.js.map +1 -0
- package/dist/app/jsonl-store.d.ts +109 -0
- package/dist/app/jsonl-store.js +402 -0
- package/dist/app/jsonl-store.js.map +1 -0
- package/dist/app/message-store.d.ts +82 -31
- package/dist/app/message-store.js +109 -48
- package/dist/app/message-store.js.map +1 -1
- package/dist/app/session-manager.d.ts +120 -57
- package/dist/app/session-manager.js +319 -123
- package/dist/app/session-manager.js.map +1 -1
- package/dist/config/paths.d.ts +48 -0
- package/dist/config/paths.js +59 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/inbound/acp/handlers/initialize.d.ts +8 -0
- package/dist/inbound/acp/handlers/initialize.js +9 -1
- package/dist/inbound/acp/handlers/initialize.js.map +1 -1
- package/dist/inbound/acp/handlers/session.d.ts +0 -1
- package/dist/orchestrator-system-prompt.js +2 -2
- package/dist/plugins/acp/index.d.ts +18 -1
- package/dist/plugins/acp/index.js +49 -3
- package/dist/plugins/acp/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,27 +6,56 @@
|
|
|
6
6
|
* `session/update` envelopes are produced by translating `AgentEvent`s
|
|
7
7
|
* received from the host bus.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* speaks `AgentHost` only. The translation of host events into ACP
|
|
11
|
-
* envelopes lives here so swapping `IAgentCore` (single-layer / pi
|
|
12
|
-
* orchestrator / mock) doesn't change ACP semantics.
|
|
9
|
+
* Persistence model — turn-grained:
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* - User prompts persist immediately on entry to `prompt()` (one
|
|
12
|
+
* `user_message` JSONL row).
|
|
13
|
+
* - Assistant chunks (`task:text`) and tool events (`task:tool`) are
|
|
14
|
+
* accumulated in an in-memory `InFlightTurnAggregator`; nothing is
|
|
15
|
+
* written until `task:end`, at which point a single
|
|
16
|
+
* `assistant_turn` JSONL row captures the entire turn (full text,
|
|
17
|
+
* every tool call in invocation order, stop reason, optional
|
|
18
|
+
* terminal error). This matches OpenClaw's "one line = one
|
|
19
|
+
* complete message" model and avoids the fragmented transcript
|
|
20
|
+
* produced by the legacy "one row per chunk" implementation.
|
|
21
|
+
*
|
|
22
|
+
* Single-session mode (`fixedSessionId`):
|
|
23
|
+
*
|
|
24
|
+
* When a fixedSessionId is supplied, every ACP session-management
|
|
25
|
+
* method (`new`/`load`/`list`/`rename`/`delete`) collapses onto that
|
|
26
|
+
* one sid. The mobile/web client receives `multipleSessions: false`
|
|
27
|
+
* in the initialize handshake so the UI hides session-switching
|
|
28
|
+
* affordances. This is the production configuration for device-agent
|
|
29
|
+
* — a single physical device serialises tasks anyway, and a fixed
|
|
30
|
+
* sid keeps the JSONL transcript stable across reconnects.
|
|
31
|
+
*
|
|
32
|
+
* Replay model — push-style:
|
|
33
|
+
*
|
|
34
|
+
* `session/load` does NOT return history in the RPC `result` (the
|
|
35
|
+
* web/mobile SDKs ignore that field). Instead, it pushes one
|
|
36
|
+
* `history_turn` envelope per persisted turn followed by an
|
|
37
|
+
* in-flight snapshot (streaming-style, WITHOUT `stream_complete`)
|
|
38
|
+
* when a task is mid-flight. This matches beeos-claw's
|
|
39
|
+
* `handleSessionLoad` exactly so the same chat-store reducer paths
|
|
40
|
+
* handle both agents.
|
|
16
41
|
*/
|
|
17
42
|
import { randomUUID } from "node:crypto";
|
|
18
43
|
import { newSession } from "../core/session.js";
|
|
19
|
-
import { MessageStore } from "./message-store.js";
|
|
44
|
+
import { MessageStore, } from "./message-store.js";
|
|
45
|
+
import { InFlightTurnAggregator, } from "./in-flight-turn.js";
|
|
20
46
|
export class SessionManager {
|
|
21
47
|
host;
|
|
22
48
|
store;
|
|
23
49
|
notifier;
|
|
24
50
|
onSetModel;
|
|
51
|
+
fixedSessionId;
|
|
25
52
|
maxSessions;
|
|
26
53
|
sessionTtlMs;
|
|
27
54
|
sessions = new Map();
|
|
28
55
|
/** taskId → sessionId for routing host events to the right ACP session. */
|
|
29
56
|
taskToSession = new Map();
|
|
57
|
+
/** Per-task in-flight aggregators. Keyed by taskId. */
|
|
58
|
+
inFlightByTask = new Map();
|
|
30
59
|
activeSessionId = null;
|
|
31
60
|
currentTask = null;
|
|
32
61
|
unsubscribe = null;
|
|
@@ -35,9 +64,17 @@ export class SessionManager {
|
|
|
35
64
|
this.store = opts.messageStore ?? new MessageStore();
|
|
36
65
|
this.notifier = opts.notifier;
|
|
37
66
|
this.onSetModel = opts.onSetModel;
|
|
67
|
+
if (opts.fixedSessionId)
|
|
68
|
+
this.fixedSessionId = opts.fixedSessionId;
|
|
38
69
|
this.maxSessions = opts.maxSessions ?? 50;
|
|
39
70
|
this.sessionTtlMs = opts.sessionTtlMs ?? 60 * 60 * 1000;
|
|
40
71
|
this.unsubscribe = this.host.on((ev) => this.handleHostEvent(ev));
|
|
72
|
+
// In single-session mode, eagerly materialise the fixed session so
|
|
73
|
+
// listSessions / load behave deterministically even before the
|
|
74
|
+
// first prompt.
|
|
75
|
+
if (this.fixedSessionId) {
|
|
76
|
+
this.ensureFixedSession();
|
|
77
|
+
}
|
|
41
78
|
}
|
|
42
79
|
/** Detach from the host bus (used when shutting down the ACP plugin). */
|
|
43
80
|
detach() {
|
|
@@ -48,6 +85,10 @@ export class SessionManager {
|
|
|
48
85
|
}
|
|
49
86
|
// ── ACP semantics — name mirrors Python SessionHandler ───────────────────
|
|
50
87
|
newSession() {
|
|
88
|
+
if (this.fixedSessionId) {
|
|
89
|
+
this.ensureFixedSession();
|
|
90
|
+
return { sessionId: this.fixedSessionId };
|
|
91
|
+
}
|
|
51
92
|
this.evictExpired();
|
|
52
93
|
const id = randomUUID();
|
|
53
94
|
this.sessions.set(id, newSession(id));
|
|
@@ -55,34 +96,70 @@ export class SessionManager {
|
|
|
55
96
|
this.store.upsertSession(id);
|
|
56
97
|
return { sessionId: id };
|
|
57
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Load a session.
|
|
101
|
+
*
|
|
102
|
+
* Push-replay model: emit one `history_turn` envelope per persisted
|
|
103
|
+
* turn so the chat-store sidecar resolver can reconstruct the bubble
|
|
104
|
+
* graph identically to a fresh streaming run. If a task is currently
|
|
105
|
+
* in-flight on this sid, additionally emit a streaming-style
|
|
106
|
+
* snapshot (`agent_message_chunk` + `tool_call` + `tool_call_update`)
|
|
107
|
+
* WITHOUT the closing `stream_complete` so the client knows the turn
|
|
108
|
+
* is still active.
|
|
109
|
+
*
|
|
110
|
+
* Returns `{ sessionId }` only — the legacy `history` field has been
|
|
111
|
+
* dropped because no client reads it (see explore audit).
|
|
112
|
+
*/
|
|
58
113
|
loadSession(sessionId) {
|
|
59
|
-
|
|
60
|
-
const known = this.sessions.has(id) || this.store.sessionExists(id);
|
|
61
|
-
if (!known)
|
|
62
|
-
id = randomUUID();
|
|
114
|
+
const id = this.aliasSessionId(sessionId);
|
|
63
115
|
if (!this.sessions.has(id))
|
|
64
116
|
this.sessions.set(id, newSession(id));
|
|
65
117
|
const s = this.sessions.get(id);
|
|
66
118
|
s.lastActive = Date.now();
|
|
67
119
|
this.activeSessionId = id;
|
|
68
120
|
this.store.upsertSession(id);
|
|
69
|
-
|
|
121
|
+
// (1) Replay every persisted turn.
|
|
122
|
+
for (const rec of this.store.getTurns(id)) {
|
|
123
|
+
this.emitHistoryTurn(id, rec);
|
|
124
|
+
}
|
|
125
|
+
// (2) Emit in-flight snapshot if a task is still running on this sid.
|
|
126
|
+
const inflight = this.findInflightForSession(id);
|
|
127
|
+
if (inflight)
|
|
128
|
+
this.emitInflightSnapshot(id, inflight);
|
|
129
|
+
return { sessionId: id };
|
|
70
130
|
}
|
|
71
131
|
listSessions() {
|
|
132
|
+
if (this.fixedSessionId) {
|
|
133
|
+
this.ensureFixedSession();
|
|
134
|
+
return { sessions: this.store.listSessions(50) };
|
|
135
|
+
}
|
|
72
136
|
this.evictExpired();
|
|
73
137
|
return { sessions: this.store.listSessions(50) };
|
|
74
138
|
}
|
|
75
139
|
renameSession(sessionId, title) {
|
|
76
|
-
const
|
|
140
|
+
const id = this.aliasSessionId(sessionId);
|
|
141
|
+
const s = this.sessions.get(id);
|
|
77
142
|
if (s) {
|
|
78
143
|
s.title = title || s.title;
|
|
79
144
|
s.lastActive = Date.now();
|
|
80
145
|
}
|
|
81
146
|
if (title)
|
|
82
|
-
this.store.renameSession(
|
|
83
|
-
return { sessionId, title };
|
|
147
|
+
this.store.renameSession(id, title);
|
|
148
|
+
return { sessionId: id, title };
|
|
84
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Delete a session.
|
|
152
|
+
*
|
|
153
|
+
* In single-session mode, this collapses to `clearSession` because
|
|
154
|
+
* the one-and-only session can't be removed without losing the
|
|
155
|
+
* device's identity. Otherwise drops the in-memory + persisted state
|
|
156
|
+
* for `sessionId`.
|
|
157
|
+
*/
|
|
85
158
|
deleteSession(sessionId) {
|
|
159
|
+
if (this.fixedSessionId) {
|
|
160
|
+
this.clearSession(this.fixedSessionId);
|
|
161
|
+
return { deleted: false };
|
|
162
|
+
}
|
|
86
163
|
const had = this.sessions.has(sessionId);
|
|
87
164
|
this.sessions.delete(sessionId);
|
|
88
165
|
if (this.activeSessionId === sessionId)
|
|
@@ -91,23 +168,30 @@ export class SessionManager {
|
|
|
91
168
|
return { deleted: had };
|
|
92
169
|
}
|
|
93
170
|
clearSession(sessionId) {
|
|
94
|
-
const
|
|
171
|
+
const id = this.aliasSessionId(sessionId);
|
|
172
|
+
const s = this.sessions.get(id);
|
|
95
173
|
if (s) {
|
|
96
174
|
s.history = [];
|
|
97
175
|
s.lastActive = Date.now();
|
|
98
176
|
}
|
|
99
|
-
this.store.clearSession(
|
|
177
|
+
this.store.clearSession(id);
|
|
178
|
+
// Also drop any pending in-flight aggregators that were targeting
|
|
179
|
+
// this sid — the user explicitly said they want a blank slate.
|
|
180
|
+
for (const [taskId, sid] of this.taskToSession) {
|
|
181
|
+
if (sid === id)
|
|
182
|
+
this.inFlightByTask.delete(taskId);
|
|
183
|
+
}
|
|
100
184
|
return { cleared: !!s };
|
|
101
185
|
}
|
|
102
186
|
setMode(sessionId, mode) {
|
|
103
|
-
return { sessionId, mode };
|
|
187
|
+
return { sessionId: this.aliasSessionId(sessionId), mode };
|
|
104
188
|
}
|
|
105
189
|
async setModel(sessionId, model, provider = "") {
|
|
106
190
|
if (!model)
|
|
107
191
|
throw new Error("model is required");
|
|
108
192
|
if (this.onSetModel)
|
|
109
193
|
await this.onSetModel(provider, model);
|
|
110
|
-
return { sessionId, provider, model };
|
|
194
|
+
return { sessionId: this.aliasSessionId(sessionId), provider, model };
|
|
111
195
|
}
|
|
112
196
|
/**
|
|
113
197
|
* Run a prompt to completion and return the ACP `stopReason`.
|
|
@@ -122,13 +206,19 @@ export class SessionManager {
|
|
|
122
206
|
const session = this.resolveSession(sessionIdHint);
|
|
123
207
|
session.lastActive = Date.now();
|
|
124
208
|
session.history.push({ role: "user", content: prompt, createdAt: session.lastActive });
|
|
125
|
-
this.store.addMessage(session.id, "user", prompt, "text");
|
|
126
209
|
this.activeSessionId = session.id;
|
|
127
210
|
this.notify(session.id, { sessionUpdate: "agent_status", status: "running" });
|
|
128
211
|
const submitted = this.host.submitTask(prompt, { correlationTag: session.id });
|
|
129
212
|
this.taskToSession.set(submitted.taskId, session.id);
|
|
130
213
|
this.currentTask = submitted;
|
|
131
214
|
session.currentTaskId = submitted.taskId;
|
|
215
|
+
// Persist the user message AFTER taskToSession is wired so a
|
|
216
|
+
// concurrent loadSession from another connection can see this row
|
|
217
|
+
// alongside the upcoming in-flight assistant snapshot.
|
|
218
|
+
// turnId mirrors agent-host's convention (turnId === taskId).
|
|
219
|
+
this.store.appendUserMessage(session.id, prompt, {
|
|
220
|
+
turnId: submitted.taskId,
|
|
221
|
+
});
|
|
132
222
|
let result;
|
|
133
223
|
try {
|
|
134
224
|
result = await submitted.done;
|
|
@@ -137,6 +227,10 @@ export class SessionManager {
|
|
|
137
227
|
this.currentTask = null;
|
|
138
228
|
session.currentTaskId = null;
|
|
139
229
|
this.taskToSession.delete(submitted.taskId);
|
|
230
|
+
// Belt-and-suspenders: in normal flow `task:end` already
|
|
231
|
+
// finalised + cleared the in-flight; defend against the agent
|
|
232
|
+
// crashing without firing task:end.
|
|
233
|
+
this.inFlightByTask.delete(submitted.taskId);
|
|
140
234
|
this.notify(session.id, { sessionUpdate: "agent_status", status: "idle" });
|
|
141
235
|
}
|
|
142
236
|
return { stopReason: result.cancelled ? "cancelled" : "end_turn" };
|
|
@@ -149,58 +243,50 @@ export class SessionManager {
|
|
|
149
243
|
}
|
|
150
244
|
// ── Internal ──────────────────────────────────────────────────────────────
|
|
151
245
|
/**
|
|
152
|
-
* Translate host `AgentEvent`s into ACP `session/update` envelopes
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* VLM rationale.
|
|
159
|
-
* - `task:tool` — start / update / end (tool_call / tool_call_update).
|
|
160
|
-
* `update` carries partial progress for long-running
|
|
161
|
-
* tools (large APK install, long shell streams,
|
|
162
|
-
* large screenshots) so the chat-store tool card
|
|
163
|
-
* can refresh in-place without waiting for `end`.
|
|
164
|
-
* Mirrors beeos-claw's tool_call_update in_progress
|
|
165
|
-
* semantics (see acp-gateway/tool-call-payload-builder.ts).
|
|
166
|
-
* - `task:notice` — out-of-band protocol notice. level=warn
|
|
167
|
-
* (transient retry hints) is dropped — noise
|
|
168
|
-
* the user can't act on. level=error (terminal
|
|
169
|
-
* failure) becomes a `system_message_chunk`
|
|
170
|
-
* with `metadata.kind = "agent_notice"`; the
|
|
171
|
-
* web client merges it into the matching
|
|
172
|
-
* assistant bubble's footer. NOT persisted.
|
|
173
|
-
* - `task:step` — structural step record (no ACP emission, kept
|
|
174
|
-
* for HTTP debug / future plugins).
|
|
175
|
-
* - `task:end` — terminal lifecycle event. We emit the canonical
|
|
176
|
-
* ACP `stream_complete` envelope here (BEFORE the
|
|
177
|
-
* `prompt()` finally block fires `agent_status:
|
|
178
|
-
* "idle"`). chat-store's `applyIncrementalUpdate`
|
|
179
|
-
* relies on `stream_complete` to (a) collapse
|
|
180
|
-
* streaming text part `state` from "streaming"
|
|
181
|
-
* back to "done" so the cursor caret stops
|
|
182
|
-
* rendering, and (b) trim `pendingMap` /
|
|
183
|
-
* `currentAsstId` so history-replay paths don't
|
|
184
|
-
* re-flag the just-finished turn as processing.
|
|
185
|
-
* Without it the bubble caret never collapses
|
|
186
|
-
* and reload paths see a stale `processing`
|
|
187
|
-
* pending — see plan
|
|
188
|
-
* `device-agent_stream_complete_对齐`.
|
|
189
|
-
*
|
|
190
|
-
* Every emitted envelope carries `_turnId` (groups all chunks of a
|
|
191
|
-
* single user prompt) and `messageId` (the per-task authoritative id
|
|
192
|
-
* shared by every chunk / thought / tool / notice in this turn). The
|
|
193
|
-
* web `chat-store` sidecar resolver pins thoughts and tool calls to
|
|
194
|
-
* the assistant bubble bearing the matching `_messageId`.
|
|
246
|
+
* Translate host `AgentEvent`s into ACP `session/update` envelopes
|
|
247
|
+
* AND accumulate the assistant turn into the in-memory aggregator
|
|
248
|
+
* for this task. The aggregator is finalised on `task:end` and
|
|
249
|
+
* persisted as a single `assistant_turn` JSONL row — chunk-level
|
|
250
|
+
* persistence is intentionally NOT done (it produces fragmented
|
|
251
|
+
* transcripts that can't be reconstructed cleanly on reload).
|
|
195
252
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
253
|
+
* Source events:
|
|
254
|
+
* - `task:start` — allocate the in-flight aggregator.
|
|
255
|
+
* - `task:text` — append delta to in-flight + emit
|
|
256
|
+
* `agent_message_chunk`.
|
|
257
|
+
* - `task:reasoning` — emit `agent_thought_chunk` only (NOT
|
|
258
|
+
* persisted — bloats LLM context on resume).
|
|
259
|
+
* - `task:tool` — track in in-flight + emit
|
|
260
|
+
* `tool_call` / `tool_call_update`. Mid-flight
|
|
261
|
+
* progress is forwarded as `in_progress`.
|
|
262
|
+
* - `task:notice` — `warn` is dropped entirely; `error` becomes
|
|
263
|
+
* a `system_message_chunk` with
|
|
264
|
+
* `metadata.kind = "agent_notice"` AND is
|
|
265
|
+
* captured as the in-flight `error` field so
|
|
266
|
+
* the persisted turn carries the failure
|
|
267
|
+
* text.
|
|
268
|
+
* - `task:step` — structural; no ACP emission.
|
|
269
|
+
* - `task:end` — finalise the in-flight, persist a single
|
|
270
|
+
* `assistant_turn` row, emit `stream_complete`
|
|
271
|
+
* BEFORE the closing `agent_status: idle`
|
|
272
|
+
* (chat-store relies on this order).
|
|
199
273
|
*/
|
|
200
274
|
handleHostEvent(ev) {
|
|
201
275
|
const sessionId = this.taskToSession.get(ev.taskId);
|
|
202
276
|
if (!sessionId)
|
|
203
277
|
return;
|
|
278
|
+
if (ev.type === "task:start") {
|
|
279
|
+
const inflight = InFlightTurnAggregator.start({
|
|
280
|
+
taskId: ev.taskId,
|
|
281
|
+
turnId: ev.turnId,
|
|
282
|
+
// task:start doesn't carry messageId — we'll populate it on
|
|
283
|
+
// the first text/tool/reasoning event below.
|
|
284
|
+
messageId: ev.taskId,
|
|
285
|
+
now: ev.createdAt,
|
|
286
|
+
});
|
|
287
|
+
this.inFlightByTask.set(ev.taskId, inflight);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
204
290
|
if (ev.type === "task:text") {
|
|
205
291
|
if (!ev.text)
|
|
206
292
|
return;
|
|
@@ -210,7 +296,8 @@ export class SessionManager {
|
|
|
210
296
|
_turnId: ev.turnId,
|
|
211
297
|
messageId: ev.messageId,
|
|
212
298
|
});
|
|
213
|
-
this.
|
|
299
|
+
const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
|
|
300
|
+
inflight.appendText(ev.text);
|
|
214
301
|
return;
|
|
215
302
|
}
|
|
216
303
|
if (ev.type === "task:reasoning") {
|
|
@@ -218,9 +305,8 @@ export class SessionManager {
|
|
|
218
305
|
// VLM rationale) flow through the dedicated `agent_thought_chunk`
|
|
219
306
|
// ACP channel so the web UI can render them in the reasoning side
|
|
220
307
|
// panel without polluting the main conversation. We intentionally
|
|
221
|
-
// do NOT persist reasoning text
|
|
222
|
-
//
|
|
223
|
-
// signal.
|
|
308
|
+
// do NOT persist reasoning text — it would bloat the LLM context
|
|
309
|
+
// window on resume and is purely a presentation signal.
|
|
224
310
|
if (!ev.text)
|
|
225
311
|
return;
|
|
226
312
|
this.notify(sessionId, {
|
|
@@ -233,6 +319,7 @@ export class SessionManager {
|
|
|
233
319
|
}
|
|
234
320
|
if (ev.type === "task:tool") {
|
|
235
321
|
const t = ev.tool;
|
|
322
|
+
const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
|
|
236
323
|
if (t.status === "start") {
|
|
237
324
|
this.notify(sessionId, {
|
|
238
325
|
sessionUpdate: "tool_call",
|
|
@@ -243,21 +330,9 @@ export class SessionManager {
|
|
|
243
330
|
_turnId: ev.turnId,
|
|
244
331
|
messageId: ev.messageId,
|
|
245
332
|
});
|
|
246
|
-
|
|
247
|
-
toolCallId: t.id,
|
|
248
|
-
parameters: t.args,
|
|
249
|
-
});
|
|
333
|
+
inflight.noteToolStart({ id: t.id, name: t.name, args: t.args });
|
|
250
334
|
}
|
|
251
335
|
else if (t.status === "update") {
|
|
252
|
-
// Mid-flight progress for a long-running tool (large APK install,
|
|
253
|
-
// streaming shell, large screenshot). Forwarded as
|
|
254
|
-
// `tool_call_update` with `status: "in_progress"` so chat-store
|
|
255
|
-
// can refresh the tool card output in-place without waiting for
|
|
256
|
-
// the terminal `end` event. Mirrors beeos-claw's in_progress
|
|
257
|
-
// semantics (acp-gateway/tool-call-payload-builder.ts L138-148).
|
|
258
|
-
// NOT persisted — only the terminal `end` writes a `tool_result`
|
|
259
|
-
// row to MessageStore so resume context isn't bloated by every
|
|
260
|
-
// intermediate progress frame.
|
|
261
336
|
const output = t.result === undefined
|
|
262
337
|
? undefined
|
|
263
338
|
: typeof t.result === "string"
|
|
@@ -273,6 +348,7 @@ export class SessionManager {
|
|
|
273
348
|
if (output !== undefined)
|
|
274
349
|
update.output = output;
|
|
275
350
|
this.notify(sessionId, update);
|
|
351
|
+
inflight.noteToolUpdate({ id: t.id, ...(output !== undefined ? { output } : {}) });
|
|
276
352
|
}
|
|
277
353
|
else if (t.status === "end") {
|
|
278
354
|
const status = t.isError ? "failed" : "completed";
|
|
@@ -285,7 +361,11 @@ export class SessionManager {
|
|
|
285
361
|
_turnId: ev.turnId,
|
|
286
362
|
messageId: ev.messageId,
|
|
287
363
|
});
|
|
288
|
-
|
|
364
|
+
inflight.noteToolEnd({
|
|
365
|
+
id: t.id,
|
|
366
|
+
output: output || `${t.name} ${status}`,
|
|
367
|
+
isError: !!t.isError,
|
|
368
|
+
});
|
|
289
369
|
}
|
|
290
370
|
return;
|
|
291
371
|
}
|
|
@@ -304,17 +384,7 @@ export class SessionManager {
|
|
|
304
384
|
// this kind and merges the notice into the matching
|
|
305
385
|
// assistant bubble's `metadata.notices` (correlated by
|
|
306
386
|
// `_turnId`), so the user sees a small inline footer line
|
|
307
|
-
// instead of a centered gray capsule.
|
|
308
|
-
// don't know `agent_notice` still render it as a plain
|
|
309
|
-
// system message — the kind tag is purely additive.
|
|
310
|
-
//
|
|
311
|
-
// Intentionally NOT written to MessageStore — replaying retry
|
|
312
|
-
// hints / terminal-failure capsules on session resume would
|
|
313
|
-
// bloat the LLM context with stale degraded-state signals. We
|
|
314
|
-
// forward `_turnId` for ordering / merge correlation but DROP
|
|
315
|
-
// `messageId` because chat-store's sidecar resolver would
|
|
316
|
-
// otherwise still try to merge a system row with assistant
|
|
317
|
-
// rows sharing that id.
|
|
387
|
+
// instead of a centered gray capsule.
|
|
318
388
|
if (ev.level === "warn")
|
|
319
389
|
return;
|
|
320
390
|
if (!ev.text)
|
|
@@ -325,38 +395,35 @@ export class SessionManager {
|
|
|
325
395
|
_turnId: ev.turnId,
|
|
326
396
|
metadata: { kind: "agent_notice", level: ev.level },
|
|
327
397
|
});
|
|
398
|
+
// Capture the terminal text on the in-flight so the persisted
|
|
399
|
+
// assistant_turn record carries an `error` field even when the
|
|
400
|
+
// model produced no final reply.
|
|
401
|
+
const inflight = this.inFlightByTask.get(ev.taskId);
|
|
402
|
+
if (inflight)
|
|
403
|
+
inflight.noteFatalNotice(ev.text);
|
|
328
404
|
return;
|
|
329
405
|
}
|
|
330
406
|
if (ev.type === "task:step") {
|
|
331
|
-
//
|
|
332
|
-
// message }`) consumed by HTTP debug / future plugins. Per-step
|
|
333
|
-
// rationale is now delivered via `task:reasoning` →
|
|
334
|
-
// `agent_thought_chunk` (see above), so we no longer mis-emit
|
|
335
|
-
// `step.thinking` as `agent_message_chunk` here — that double-fire
|
|
336
|
-
// was leaking VLM rationale into the user-visible reply.
|
|
407
|
+
// Structural event — consumed by HTTP debug. No ACP emission.
|
|
337
408
|
return;
|
|
338
409
|
}
|
|
339
410
|
if (ev.type === "task:end") {
|
|
340
|
-
const status = ev.error ? "failed" : ev.result.cancelled ? "cancelled" : "completed";
|
|
341
|
-
// Emit ACP `stream_complete` BEFORE the `prompt()` finally block
|
|
342
|
-
// fires `agent_status: "idle"`. Order matters: chat-store treats
|
|
343
|
-
// `stream_complete` as the canonical "this turn is over" signal
|
|
344
|
-
// (collapses text part `state: "streaming"` → "done", trims
|
|
345
|
-
// `pendingMap` / `currentAsstId`). Sending it from the host event
|
|
346
|
-
// path guarantees it arrives before idle because `task:end` is
|
|
347
|
-
// dispatched synchronously from `agent-host.runOne` BEFORE the
|
|
348
|
-
// task's `done` Promise resolves — and the `prompt()` finally is
|
|
349
|
-
// only entered after `await submitted.done`.
|
|
350
|
-
//
|
|
351
|
-
// `stopReason` mirrors beeos-claw's three-valued enum (`end_turn`
|
|
352
|
-
// / `cancelled` / `error`). The `error` field is forwarded only on
|
|
353
|
-
// the failure path so chat-store can surface it on the bubble
|
|
354
|
-
// footer; success / cancel envelopes omit it entirely.
|
|
355
411
|
const stopReason = ev.error
|
|
356
412
|
? "error"
|
|
357
413
|
: ev.result.cancelled
|
|
358
414
|
? "cancelled"
|
|
359
415
|
: "end_turn";
|
|
416
|
+
// Finalise + persist BEFORE emitting `stream_complete`. This
|
|
417
|
+
// ordering matters for any concurrent `session/load` that comes
|
|
418
|
+
// in between `stream_complete` and the prompt's idle signal —
|
|
419
|
+
// the JSONL row should be on disk by the time we tell the
|
|
420
|
+
// client the turn is done.
|
|
421
|
+
const inflight = this.inFlightByTask.get(ev.taskId);
|
|
422
|
+
if (inflight) {
|
|
423
|
+
const record = inflight.finalize(stopReason, ev.completedAt);
|
|
424
|
+
this.store.appendAssistantTurn(sessionId, record);
|
|
425
|
+
this.inFlightByTask.delete(ev.taskId);
|
|
426
|
+
}
|
|
360
427
|
const completeUpdate = {
|
|
361
428
|
sessionUpdate: "stream_complete",
|
|
362
429
|
stopReason,
|
|
@@ -365,13 +432,13 @@ export class SessionManager {
|
|
|
365
432
|
if (ev.error)
|
|
366
433
|
completeUpdate.error = ev.error;
|
|
367
434
|
this.notify(sessionId, completeUpdate);
|
|
368
|
-
this.store.addMessage(sessionId, "system", `Task ${status}`, "status", {
|
|
369
|
-
taskId: ev.taskId,
|
|
370
|
-
status,
|
|
371
|
-
});
|
|
372
435
|
}
|
|
373
436
|
}
|
|
374
437
|
resolveSession(idHint) {
|
|
438
|
+
if (this.fixedSessionId) {
|
|
439
|
+
this.ensureFixedSession();
|
|
440
|
+
return this.sessions.get(this.fixedSessionId);
|
|
441
|
+
}
|
|
375
442
|
if (idHint && this.sessions.has(idHint))
|
|
376
443
|
return this.sessions.get(idHint);
|
|
377
444
|
if (idHint && this.store.sessionExists(idHint)) {
|
|
@@ -384,12 +451,143 @@ export class SessionManager {
|
|
|
384
451
|
const { sessionId } = this.newSession();
|
|
385
452
|
return this.sessions.get(sessionId);
|
|
386
453
|
}
|
|
454
|
+
/** Map any sid the client passes onto the canonical sid in fixed mode. */
|
|
455
|
+
aliasSessionId(sid) {
|
|
456
|
+
return this.fixedSessionId ?? sid;
|
|
457
|
+
}
|
|
458
|
+
/** Materialise the fixed session in-memory + on disk if it isn't yet. */
|
|
459
|
+
ensureFixedSession() {
|
|
460
|
+
if (!this.fixedSessionId)
|
|
461
|
+
return;
|
|
462
|
+
if (!this.sessions.has(this.fixedSessionId)) {
|
|
463
|
+
this.sessions.set(this.fixedSessionId, newSession(this.fixedSessionId));
|
|
464
|
+
}
|
|
465
|
+
if (!this.store.sessionExists(this.fixedSessionId)) {
|
|
466
|
+
this.store.upsertSession(this.fixedSessionId);
|
|
467
|
+
}
|
|
468
|
+
this.activeSessionId = this.fixedSessionId;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Lazily create an in-flight aggregator for events that arrive
|
|
472
|
+
* without a preceding `task:start` (defensive — agent-host always
|
|
473
|
+
* fires start, but tests + alternate adapters may not).
|
|
474
|
+
*/
|
|
475
|
+
ensureInflight(taskId, turnId, messageId) {
|
|
476
|
+
let inflight = this.inFlightByTask.get(taskId);
|
|
477
|
+
if (!inflight) {
|
|
478
|
+
inflight = InFlightTurnAggregator.start({ taskId, turnId, messageId });
|
|
479
|
+
this.inFlightByTask.set(taskId, inflight);
|
|
480
|
+
}
|
|
481
|
+
return inflight;
|
|
482
|
+
}
|
|
483
|
+
findInflightForSession(sessionId) {
|
|
484
|
+
for (const [taskId, sid] of this.taskToSession) {
|
|
485
|
+
if (sid !== sessionId)
|
|
486
|
+
continue;
|
|
487
|
+
const inflight = this.inFlightByTask.get(taskId);
|
|
488
|
+
if (inflight)
|
|
489
|
+
return inflight;
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Emit a single persisted record as a `history_turn` envelope.
|
|
495
|
+
*
|
|
496
|
+
* Mirrors beeos-claw `bridge-core.replayAssistantTurnAsStream` —
|
|
497
|
+
* `history_turn` is a stamped-done snapshot of an entire turn that
|
|
498
|
+
* the chat-store reducer renders as a complete bubble (no streaming
|
|
499
|
+
* cursor). For assistant turns, we serialise tool calls into the
|
|
500
|
+
* shape `apply-update.buildToolCalls` accepts.
|
|
501
|
+
*/
|
|
502
|
+
emitHistoryTurn(sessionId, rec) {
|
|
503
|
+
if (rec.type === "user_message") {
|
|
504
|
+
const update = {
|
|
505
|
+
sessionUpdate: "history_turn",
|
|
506
|
+
role: "user",
|
|
507
|
+
content: rec.content,
|
|
508
|
+
timestamp: new Date(rec.ts).toISOString(),
|
|
509
|
+
};
|
|
510
|
+
if (rec.messageId)
|
|
511
|
+
update.messageId = rec.messageId;
|
|
512
|
+
if (rec.turnId)
|
|
513
|
+
update._turnId = rec.turnId;
|
|
514
|
+
this.notify(sessionId, update);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// assistant_turn
|
|
518
|
+
const turn = rec;
|
|
519
|
+
const toolCalls = turn.toolCalls.map((tc) => ({
|
|
520
|
+
id: tc.id,
|
|
521
|
+
name: tc.name,
|
|
522
|
+
arguments: typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args ?? {}),
|
|
523
|
+
status: tc.status,
|
|
524
|
+
...(tc.output !== undefined ? { output: tc.output } : {}),
|
|
525
|
+
isError: tc.status === "failed",
|
|
526
|
+
}));
|
|
527
|
+
const update = {
|
|
528
|
+
sessionUpdate: "history_turn",
|
|
529
|
+
role: "assistant",
|
|
530
|
+
content: turn.text,
|
|
531
|
+
timestamp: new Date(turn.ts).toISOString(),
|
|
532
|
+
messageId: turn.messageId,
|
|
533
|
+
_turnId: turn.turnId,
|
|
534
|
+
};
|
|
535
|
+
if (toolCalls.length > 0)
|
|
536
|
+
update.toolCalls = toolCalls;
|
|
537
|
+
if (turn.error)
|
|
538
|
+
update.metadata = { error: turn.error };
|
|
539
|
+
this.notify(sessionId, update);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Emit the current state of an in-flight turn as streaming-style
|
|
543
|
+
* envelopes WITHOUT the closing `stream_complete`. The client uses
|
|
544
|
+
* the absence of `stream_complete` to recognise the turn is still
|
|
545
|
+
* generating, matching beeos-claw's `emitCurrentStreamSnapshot`.
|
|
546
|
+
*/
|
|
547
|
+
emitInflightSnapshot(sessionId, inflight) {
|
|
548
|
+
const text = inflight.currentText();
|
|
549
|
+
if (text) {
|
|
550
|
+
this.notify(sessionId, {
|
|
551
|
+
sessionUpdate: "agent_message_chunk",
|
|
552
|
+
content: { type: "text", text },
|
|
553
|
+
_turnId: inflight.turnId,
|
|
554
|
+
messageId: inflight.messageId,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
for (const tc of inflight.currentToolCalls()) {
|
|
558
|
+
this.notify(sessionId, {
|
|
559
|
+
sessionUpdate: "tool_call",
|
|
560
|
+
toolCallId: tc.id,
|
|
561
|
+
title: tc.name,
|
|
562
|
+
arguments: JSON.stringify(tc.args),
|
|
563
|
+
status: "in_progress",
|
|
564
|
+
_turnId: inflight.turnId,
|
|
565
|
+
messageId: inflight.messageId,
|
|
566
|
+
});
|
|
567
|
+
if (tc.status !== "in_progress") {
|
|
568
|
+
const update = {
|
|
569
|
+
sessionUpdate: "tool_call_update",
|
|
570
|
+
toolCallId: tc.id,
|
|
571
|
+
status: tc.status,
|
|
572
|
+
_turnId: inflight.turnId,
|
|
573
|
+
messageId: inflight.messageId,
|
|
574
|
+
};
|
|
575
|
+
if (tc.output !== undefined)
|
|
576
|
+
update.output = tc.output;
|
|
577
|
+
this.notify(sessionId, update);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Deliberately do NOT emit `stream_complete` — its absence is the
|
|
581
|
+
// signal the client uses to recognise a still-generating turn.
|
|
582
|
+
}
|
|
387
583
|
notify(sessionId, update) {
|
|
388
584
|
if (!this.notifier)
|
|
389
585
|
return;
|
|
390
586
|
this.notifier.send(sessionId, update);
|
|
391
587
|
}
|
|
392
588
|
evictExpired() {
|
|
589
|
+
if (this.fixedSessionId)
|
|
590
|
+
return;
|
|
393
591
|
const now = Date.now();
|
|
394
592
|
for (const [id, s] of this.sessions) {
|
|
395
593
|
if (now - s.lastActive > this.sessionTtlMs) {
|
|
@@ -417,9 +615,7 @@ export class SessionManager {
|
|
|
417
615
|
* `resource_link` URIs are preserved verbatim on their own line at the
|
|
418
616
|
* end of the merged prompt, so the Resource Resolution Layer's parsers
|
|
419
617
|
* (`UrlParser`, `BeeosFileParser`) can pick them up without any extra
|
|
420
|
-
* out-of-band channel.
|
|
421
|
-
* "the agent has access to a user-supplied resource" path goes through
|
|
422
|
-
* here.
|
|
618
|
+
* out-of-band channel.
|
|
423
619
|
*/
|
|
424
620
|
function normalizePrompt(raw) {
|
|
425
621
|
if (typeof raw === "string")
|