@beeos-ai/device-agent 0.5.1 → 0.7.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/adapters/agent-gateway-client.d.ts +25 -0
- package/dist/adapters/agent-gateway-client.js +49 -0
- package/dist/adapters/agent-gateway-client.js.map +1 -1
- package/dist/adapters/user-file-uploader.d.ts +74 -0
- package/dist/adapters/user-file-uploader.js +78 -0
- package/dist/adapters/user-file-uploader.js.map +1 -0
- 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/plugin-commands/registry.d.ts +41 -0
- package/dist/app/plugin-commands/registry.js +62 -0
- package/dist/app/plugin-commands/registry.js.map +1 -0
- package/dist/app/plugin-commands/screenshot-command.d.ts +32 -0
- package/dist/app/plugin-commands/screenshot-command.js +44 -0
- package/dist/app/plugin-commands/screenshot-command.js.map +1 -0
- package/dist/app/plugin-commands/types.d.ts +63 -0
- package/dist/app/plugin-commands/types.js +26 -0
- package/dist/app/plugin-commands/types.js.map +1 -0
- package/dist/app/screenshot-service.d.ts +68 -0
- package/dist/app/screenshot-service.js +64 -0
- package/dist/app/screenshot-service.js.map +1 -0
- package/dist/app/session-manager.d.ts +150 -55
- package/dist/app/session-manager.js +385 -122
- package/dist/app/session-manager.js.map +1 -1
- package/dist/bootstrap.js +5 -1
- package/dist/bootstrap.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/device.d.ts +9 -1
- package/dist/inbound/acp/handlers/device.js +9 -1
- package/dist/inbound/acp/handlers/device.js.map +1 -1
- package/dist/inbound/acp/handlers/initialize.d.ts +17 -5
- package/dist/inbound/acp/handlers/initialize.js +19 -7
- package/dist/inbound/acp/handlers/initialize.js.map +1 -1
- package/dist/inbound/acp/handlers/session.d.ts +0 -1
- package/dist/inbound/acp/handlers/skills.d.ts +24 -2
- package/dist/inbound/acp/handlers/skills.js +31 -2
- package/dist/inbound/acp/handlers/skills.js.map +1 -1
- package/dist/orchestrator-system-prompt.js +2 -2
- package/dist/plugins/acp/index.d.ts +26 -1
- package/dist/plugins/acp/index.js +86 -3
- package/dist/plugins/acp/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,27 +6,57 @@
|
|
|
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;
|
|
54
|
+
pluginCommands;
|
|
27
55
|
sessions = new Map();
|
|
28
56
|
/** taskId → sessionId for routing host events to the right ACP session. */
|
|
29
57
|
taskToSession = new Map();
|
|
58
|
+
/** Per-task in-flight aggregators. Keyed by taskId. */
|
|
59
|
+
inFlightByTask = new Map();
|
|
30
60
|
activeSessionId = null;
|
|
31
61
|
currentTask = null;
|
|
32
62
|
unsubscribe = null;
|
|
@@ -35,9 +65,18 @@ export class SessionManager {
|
|
|
35
65
|
this.store = opts.messageStore ?? new MessageStore();
|
|
36
66
|
this.notifier = opts.notifier;
|
|
37
67
|
this.onSetModel = opts.onSetModel;
|
|
68
|
+
if (opts.fixedSessionId)
|
|
69
|
+
this.fixedSessionId = opts.fixedSessionId;
|
|
38
70
|
this.maxSessions = opts.maxSessions ?? 50;
|
|
39
71
|
this.sessionTtlMs = opts.sessionTtlMs ?? 60 * 60 * 1000;
|
|
72
|
+
this.pluginCommands = opts.pluginCommands;
|
|
40
73
|
this.unsubscribe = this.host.on((ev) => this.handleHostEvent(ev));
|
|
74
|
+
// In single-session mode, eagerly materialise the fixed session so
|
|
75
|
+
// listSessions / load behave deterministically even before the
|
|
76
|
+
// first prompt.
|
|
77
|
+
if (this.fixedSessionId) {
|
|
78
|
+
this.ensureFixedSession();
|
|
79
|
+
}
|
|
41
80
|
}
|
|
42
81
|
/** Detach from the host bus (used when shutting down the ACP plugin). */
|
|
43
82
|
detach() {
|
|
@@ -48,6 +87,10 @@ export class SessionManager {
|
|
|
48
87
|
}
|
|
49
88
|
// ── ACP semantics — name mirrors Python SessionHandler ───────────────────
|
|
50
89
|
newSession() {
|
|
90
|
+
if (this.fixedSessionId) {
|
|
91
|
+
this.ensureFixedSession();
|
|
92
|
+
return { sessionId: this.fixedSessionId };
|
|
93
|
+
}
|
|
51
94
|
this.evictExpired();
|
|
52
95
|
const id = randomUUID();
|
|
53
96
|
this.sessions.set(id, newSession(id));
|
|
@@ -55,34 +98,70 @@ export class SessionManager {
|
|
|
55
98
|
this.store.upsertSession(id);
|
|
56
99
|
return { sessionId: id };
|
|
57
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Load a session.
|
|
103
|
+
*
|
|
104
|
+
* Push-replay model: emit one `history_turn` envelope per persisted
|
|
105
|
+
* turn so the chat-store sidecar resolver can reconstruct the bubble
|
|
106
|
+
* graph identically to a fresh streaming run. If a task is currently
|
|
107
|
+
* in-flight on this sid, additionally emit a streaming-style
|
|
108
|
+
* snapshot (`agent_message_chunk` + `tool_call` + `tool_call_update`)
|
|
109
|
+
* WITHOUT the closing `stream_complete` so the client knows the turn
|
|
110
|
+
* is still active.
|
|
111
|
+
*
|
|
112
|
+
* Returns `{ sessionId }` only — the legacy `history` field has been
|
|
113
|
+
* dropped because no client reads it (see explore audit).
|
|
114
|
+
*/
|
|
58
115
|
loadSession(sessionId) {
|
|
59
|
-
|
|
60
|
-
const known = this.sessions.has(id) || this.store.sessionExists(id);
|
|
61
|
-
if (!known)
|
|
62
|
-
id = randomUUID();
|
|
116
|
+
const id = this.aliasSessionId(sessionId);
|
|
63
117
|
if (!this.sessions.has(id))
|
|
64
118
|
this.sessions.set(id, newSession(id));
|
|
65
119
|
const s = this.sessions.get(id);
|
|
66
120
|
s.lastActive = Date.now();
|
|
67
121
|
this.activeSessionId = id;
|
|
68
122
|
this.store.upsertSession(id);
|
|
69
|
-
|
|
123
|
+
// (1) Replay every persisted turn.
|
|
124
|
+
for (const rec of this.store.getTurns(id)) {
|
|
125
|
+
this.emitHistoryTurn(id, rec);
|
|
126
|
+
}
|
|
127
|
+
// (2) Emit in-flight snapshot if a task is still running on this sid.
|
|
128
|
+
const inflight = this.findInflightForSession(id);
|
|
129
|
+
if (inflight)
|
|
130
|
+
this.emitInflightSnapshot(id, inflight);
|
|
131
|
+
return { sessionId: id };
|
|
70
132
|
}
|
|
71
133
|
listSessions() {
|
|
134
|
+
if (this.fixedSessionId) {
|
|
135
|
+
this.ensureFixedSession();
|
|
136
|
+
return { sessions: this.store.listSessions(50) };
|
|
137
|
+
}
|
|
72
138
|
this.evictExpired();
|
|
73
139
|
return { sessions: this.store.listSessions(50) };
|
|
74
140
|
}
|
|
75
141
|
renameSession(sessionId, title) {
|
|
76
|
-
const
|
|
142
|
+
const id = this.aliasSessionId(sessionId);
|
|
143
|
+
const s = this.sessions.get(id);
|
|
77
144
|
if (s) {
|
|
78
145
|
s.title = title || s.title;
|
|
79
146
|
s.lastActive = Date.now();
|
|
80
147
|
}
|
|
81
148
|
if (title)
|
|
82
|
-
this.store.renameSession(
|
|
83
|
-
return { sessionId, title };
|
|
149
|
+
this.store.renameSession(id, title);
|
|
150
|
+
return { sessionId: id, title };
|
|
84
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Delete a session.
|
|
154
|
+
*
|
|
155
|
+
* In single-session mode, this collapses to `clearSession` because
|
|
156
|
+
* the one-and-only session can't be removed without losing the
|
|
157
|
+
* device's identity. Otherwise drops the in-memory + persisted state
|
|
158
|
+
* for `sessionId`.
|
|
159
|
+
*/
|
|
85
160
|
deleteSession(sessionId) {
|
|
161
|
+
if (this.fixedSessionId) {
|
|
162
|
+
this.clearSession(this.fixedSessionId);
|
|
163
|
+
return { deleted: false };
|
|
164
|
+
}
|
|
86
165
|
const had = this.sessions.has(sessionId);
|
|
87
166
|
this.sessions.delete(sessionId);
|
|
88
167
|
if (this.activeSessionId === sessionId)
|
|
@@ -91,23 +170,30 @@ export class SessionManager {
|
|
|
91
170
|
return { deleted: had };
|
|
92
171
|
}
|
|
93
172
|
clearSession(sessionId) {
|
|
94
|
-
const
|
|
173
|
+
const id = this.aliasSessionId(sessionId);
|
|
174
|
+
const s = this.sessions.get(id);
|
|
95
175
|
if (s) {
|
|
96
176
|
s.history = [];
|
|
97
177
|
s.lastActive = Date.now();
|
|
98
178
|
}
|
|
99
|
-
this.store.clearSession(
|
|
179
|
+
this.store.clearSession(id);
|
|
180
|
+
// Also drop any pending in-flight aggregators that were targeting
|
|
181
|
+
// this sid — the user explicitly said they want a blank slate.
|
|
182
|
+
for (const [taskId, sid] of this.taskToSession) {
|
|
183
|
+
if (sid === id)
|
|
184
|
+
this.inFlightByTask.delete(taskId);
|
|
185
|
+
}
|
|
100
186
|
return { cleared: !!s };
|
|
101
187
|
}
|
|
102
188
|
setMode(sessionId, mode) {
|
|
103
|
-
return { sessionId, mode };
|
|
189
|
+
return { sessionId: this.aliasSessionId(sessionId), mode };
|
|
104
190
|
}
|
|
105
191
|
async setModel(sessionId, model, provider = "") {
|
|
106
192
|
if (!model)
|
|
107
193
|
throw new Error("model is required");
|
|
108
194
|
if (this.onSetModel)
|
|
109
195
|
await this.onSetModel(provider, model);
|
|
110
|
-
return { sessionId, provider, model };
|
|
196
|
+
return { sessionId: this.aliasSessionId(sessionId), provider, model };
|
|
111
197
|
}
|
|
112
198
|
/**
|
|
113
199
|
* Run a prompt to completion and return the ACP `stopReason`.
|
|
@@ -122,13 +208,31 @@ export class SessionManager {
|
|
|
122
208
|
const session = this.resolveSession(sessionIdHint);
|
|
123
209
|
session.lastActive = Date.now();
|
|
124
210
|
session.history.push({ role: "user", content: prompt, createdAt: session.lastActive });
|
|
125
|
-
this.store.addMessage(session.id, "user", prompt, "text");
|
|
126
211
|
this.activeSessionId = session.id;
|
|
212
|
+
// Plugin command short-circuit: if the prompt is a /command match,
|
|
213
|
+
// dispatch straight to the registered handler and skip the
|
|
214
|
+
// AgentRunner entirely. Mirrors OpenClaw `matchPluginCommand` —
|
|
215
|
+
// see `openclaw/src/plugins/commands.ts`. Natural-language prompts
|
|
216
|
+
// ("截屏发给我") flow through unchanged because `match()` only
|
|
217
|
+
// returns hits for `/`-prefixed text.
|
|
218
|
+
if (this.pluginCommands) {
|
|
219
|
+
const matched = this.pluginCommands.match(prompt);
|
|
220
|
+
if (matched) {
|
|
221
|
+
return this.handlePluginCommand(prompt, session.id, matched.command, matched.args);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
127
224
|
this.notify(session.id, { sessionUpdate: "agent_status", status: "running" });
|
|
128
225
|
const submitted = this.host.submitTask(prompt, { correlationTag: session.id });
|
|
129
226
|
this.taskToSession.set(submitted.taskId, session.id);
|
|
130
227
|
this.currentTask = submitted;
|
|
131
228
|
session.currentTaskId = submitted.taskId;
|
|
229
|
+
// Persist the user message AFTER taskToSession is wired so a
|
|
230
|
+
// concurrent loadSession from another connection can see this row
|
|
231
|
+
// alongside the upcoming in-flight assistant snapshot.
|
|
232
|
+
// turnId mirrors agent-host's convention (turnId === taskId).
|
|
233
|
+
this.store.appendUserMessage(session.id, prompt, {
|
|
234
|
+
turnId: submitted.taskId,
|
|
235
|
+
});
|
|
132
236
|
let result;
|
|
133
237
|
try {
|
|
134
238
|
result = await submitted.done;
|
|
@@ -137,6 +241,10 @@ export class SessionManager {
|
|
|
137
241
|
this.currentTask = null;
|
|
138
242
|
session.currentTaskId = null;
|
|
139
243
|
this.taskToSession.delete(submitted.taskId);
|
|
244
|
+
// Belt-and-suspenders: in normal flow `task:end` already
|
|
245
|
+
// finalised + cleared the in-flight; defend against the agent
|
|
246
|
+
// crashing without firing task:end.
|
|
247
|
+
this.inFlightByTask.delete(submitted.taskId);
|
|
140
248
|
this.notify(session.id, { sessionUpdate: "agent_status", status: "idle" });
|
|
141
249
|
}
|
|
142
250
|
return { stopReason: result.cancelled ? "cancelled" : "end_turn" };
|
|
@@ -147,60 +255,105 @@ export class SessionManager {
|
|
|
147
255
|
return false;
|
|
148
256
|
return this.currentTask.cancel();
|
|
149
257
|
}
|
|
150
|
-
// ── Internal ──────────────────────────────────────────────────────────────
|
|
151
258
|
/**
|
|
152
|
-
*
|
|
259
|
+
* Dispatch a `/command` prompt to its registered handler, bypassing
|
|
260
|
+
* the AgentRunner / LLM. The wire shape stays compatible with the
|
|
261
|
+
* regular prompt path:
|
|
262
|
+
*
|
|
263
|
+
* 1. user message persisted (so reload sees the typed `/screenshot`)
|
|
264
|
+
* 2. `agent_status: running`
|
|
265
|
+
* 3. handler runs — typically emits its own `agent_message_chunk`
|
|
266
|
+
* with a `resource_link` (see `ScreenshotCommandHandler`)
|
|
267
|
+
* 4. `stream_complete` + `agent_status: idle`
|
|
153
268
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* - `task:reasoning` — streaming "thinking" delta (agent_thought_chunk):
|
|
157
|
-
* planner plan/replan descriptions and per-step
|
|
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_对齐`.
|
|
269
|
+
* Errors from the handler are converted into a `system_message_chunk`
|
|
270
|
+
* notice (level=error) instead of crashing the whole session.
|
|
189
271
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
272
|
+
* NOTE: assistant turns produced by plugin commands are intentionally
|
|
273
|
+
* NOT persisted — slash command output is time-sensitive (a stale
|
|
274
|
+
* screenshot helps no one) and the user should re-issue the command
|
|
275
|
+
* on reconnect. The user-side `/screenshot` text IS persisted so the
|
|
276
|
+
* conversation log makes sense.
|
|
277
|
+
*/
|
|
278
|
+
async handlePluginCommand(prompt, sessionId, command, args) {
|
|
279
|
+
const turnId = randomUUID();
|
|
280
|
+
// Persist user message first so JSONL on disk survives a crash mid-handler.
|
|
281
|
+
this.store.appendUserMessage(sessionId, prompt, { turnId });
|
|
282
|
+
this.notify(sessionId, { sessionUpdate: "agent_status", status: "running" });
|
|
283
|
+
let stopReason = "end_turn";
|
|
284
|
+
let errorText;
|
|
285
|
+
try {
|
|
286
|
+
await command.handle({ sessionId, args });
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
stopReason = "error";
|
|
290
|
+
errorText = e instanceof Error ? e.message : String(e);
|
|
291
|
+
this.notify(sessionId, {
|
|
292
|
+
sessionUpdate: "system_message_chunk",
|
|
293
|
+
content: { type: "text", text: `${command.name}: ${errorText}` },
|
|
294
|
+
metadata: { kind: "agent_notice", level: "error" },
|
|
295
|
+
_turnId: turnId,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
const completeUpdate = {
|
|
300
|
+
sessionUpdate: "stream_complete",
|
|
301
|
+
stopReason,
|
|
302
|
+
_turnId: turnId,
|
|
303
|
+
};
|
|
304
|
+
if (errorText)
|
|
305
|
+
completeUpdate.error = errorText;
|
|
306
|
+
this.notify(sessionId, completeUpdate);
|
|
307
|
+
this.notify(sessionId, { sessionUpdate: "agent_status", status: "idle" });
|
|
308
|
+
}
|
|
309
|
+
return { stopReason };
|
|
310
|
+
}
|
|
311
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
312
|
+
/**
|
|
313
|
+
* Translate host `AgentEvent`s into ACP `session/update` envelopes
|
|
314
|
+
* AND accumulate the assistant turn into the in-memory aggregator
|
|
315
|
+
* for this task. The aggregator is finalised on `task:end` and
|
|
316
|
+
* persisted as a single `assistant_turn` JSONL row — chunk-level
|
|
317
|
+
* persistence is intentionally NOT done (it produces fragmented
|
|
318
|
+
* transcripts that can't be reconstructed cleanly on reload).
|
|
195
319
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
320
|
+
* Source events:
|
|
321
|
+
* - `task:start` — allocate the in-flight aggregator.
|
|
322
|
+
* - `task:text` — append delta to in-flight + emit
|
|
323
|
+
* `agent_message_chunk`.
|
|
324
|
+
* - `task:reasoning` — emit `agent_thought_chunk` only (NOT
|
|
325
|
+
* persisted — bloats LLM context on resume).
|
|
326
|
+
* - `task:tool` — track in in-flight + emit
|
|
327
|
+
* `tool_call` / `tool_call_update`. Mid-flight
|
|
328
|
+
* progress is forwarded as `in_progress`.
|
|
329
|
+
* - `task:notice` — `warn` is dropped entirely; `error` becomes
|
|
330
|
+
* a `system_message_chunk` with
|
|
331
|
+
* `metadata.kind = "agent_notice"` AND is
|
|
332
|
+
* captured as the in-flight `error` field so
|
|
333
|
+
* the persisted turn carries the failure
|
|
334
|
+
* text.
|
|
335
|
+
* - `task:step` — structural; no ACP emission.
|
|
336
|
+
* - `task:end` — finalise the in-flight, persist a single
|
|
337
|
+
* `assistant_turn` row, emit `stream_complete`
|
|
338
|
+
* BEFORE the closing `agent_status: idle`
|
|
339
|
+
* (chat-store relies on this order).
|
|
199
340
|
*/
|
|
200
341
|
handleHostEvent(ev) {
|
|
201
342
|
const sessionId = this.taskToSession.get(ev.taskId);
|
|
202
343
|
if (!sessionId)
|
|
203
344
|
return;
|
|
345
|
+
if (ev.type === "task:start") {
|
|
346
|
+
const inflight = InFlightTurnAggregator.start({
|
|
347
|
+
taskId: ev.taskId,
|
|
348
|
+
turnId: ev.turnId,
|
|
349
|
+
// task:start doesn't carry messageId — we'll populate it on
|
|
350
|
+
// the first text/tool/reasoning event below.
|
|
351
|
+
messageId: ev.taskId,
|
|
352
|
+
now: ev.createdAt,
|
|
353
|
+
});
|
|
354
|
+
this.inFlightByTask.set(ev.taskId, inflight);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
204
357
|
if (ev.type === "task:text") {
|
|
205
358
|
if (!ev.text)
|
|
206
359
|
return;
|
|
@@ -210,7 +363,8 @@ export class SessionManager {
|
|
|
210
363
|
_turnId: ev.turnId,
|
|
211
364
|
messageId: ev.messageId,
|
|
212
365
|
});
|
|
213
|
-
this.
|
|
366
|
+
const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
|
|
367
|
+
inflight.appendText(ev.text);
|
|
214
368
|
return;
|
|
215
369
|
}
|
|
216
370
|
if (ev.type === "task:reasoning") {
|
|
@@ -218,9 +372,8 @@ export class SessionManager {
|
|
|
218
372
|
// VLM rationale) flow through the dedicated `agent_thought_chunk`
|
|
219
373
|
// ACP channel so the web UI can render them in the reasoning side
|
|
220
374
|
// panel without polluting the main conversation. We intentionally
|
|
221
|
-
// do NOT persist reasoning text
|
|
222
|
-
//
|
|
223
|
-
// signal.
|
|
375
|
+
// do NOT persist reasoning text — it would bloat the LLM context
|
|
376
|
+
// window on resume and is purely a presentation signal.
|
|
224
377
|
if (!ev.text)
|
|
225
378
|
return;
|
|
226
379
|
this.notify(sessionId, {
|
|
@@ -233,6 +386,7 @@ export class SessionManager {
|
|
|
233
386
|
}
|
|
234
387
|
if (ev.type === "task:tool") {
|
|
235
388
|
const t = ev.tool;
|
|
389
|
+
const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
|
|
236
390
|
if (t.status === "start") {
|
|
237
391
|
this.notify(sessionId, {
|
|
238
392
|
sessionUpdate: "tool_call",
|
|
@@ -243,21 +397,9 @@ export class SessionManager {
|
|
|
243
397
|
_turnId: ev.turnId,
|
|
244
398
|
messageId: ev.messageId,
|
|
245
399
|
});
|
|
246
|
-
|
|
247
|
-
toolCallId: t.id,
|
|
248
|
-
parameters: t.args,
|
|
249
|
-
});
|
|
400
|
+
inflight.noteToolStart({ id: t.id, name: t.name, args: t.args });
|
|
250
401
|
}
|
|
251
402
|
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
403
|
const output = t.result === undefined
|
|
262
404
|
? undefined
|
|
263
405
|
: typeof t.result === "string"
|
|
@@ -273,6 +415,7 @@ export class SessionManager {
|
|
|
273
415
|
if (output !== undefined)
|
|
274
416
|
update.output = output;
|
|
275
417
|
this.notify(sessionId, update);
|
|
418
|
+
inflight.noteToolUpdate({ id: t.id, ...(output !== undefined ? { output } : {}) });
|
|
276
419
|
}
|
|
277
420
|
else if (t.status === "end") {
|
|
278
421
|
const status = t.isError ? "failed" : "completed";
|
|
@@ -285,7 +428,11 @@ export class SessionManager {
|
|
|
285
428
|
_turnId: ev.turnId,
|
|
286
429
|
messageId: ev.messageId,
|
|
287
430
|
});
|
|
288
|
-
|
|
431
|
+
inflight.noteToolEnd({
|
|
432
|
+
id: t.id,
|
|
433
|
+
output: output || `${t.name} ${status}`,
|
|
434
|
+
isError: !!t.isError,
|
|
435
|
+
});
|
|
289
436
|
}
|
|
290
437
|
return;
|
|
291
438
|
}
|
|
@@ -304,17 +451,7 @@ export class SessionManager {
|
|
|
304
451
|
// this kind and merges the notice into the matching
|
|
305
452
|
// assistant bubble's `metadata.notices` (correlated by
|
|
306
453
|
// `_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.
|
|
454
|
+
// instead of a centered gray capsule.
|
|
318
455
|
if (ev.level === "warn")
|
|
319
456
|
return;
|
|
320
457
|
if (!ev.text)
|
|
@@ -325,38 +462,35 @@ export class SessionManager {
|
|
|
325
462
|
_turnId: ev.turnId,
|
|
326
463
|
metadata: { kind: "agent_notice", level: ev.level },
|
|
327
464
|
});
|
|
465
|
+
// Capture the terminal text on the in-flight so the persisted
|
|
466
|
+
// assistant_turn record carries an `error` field even when the
|
|
467
|
+
// model produced no final reply.
|
|
468
|
+
const inflight = this.inFlightByTask.get(ev.taskId);
|
|
469
|
+
if (inflight)
|
|
470
|
+
inflight.noteFatalNotice(ev.text);
|
|
328
471
|
return;
|
|
329
472
|
}
|
|
330
473
|
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.
|
|
474
|
+
// Structural event — consumed by HTTP debug. No ACP emission.
|
|
337
475
|
return;
|
|
338
476
|
}
|
|
339
477
|
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
478
|
const stopReason = ev.error
|
|
356
479
|
? "error"
|
|
357
480
|
: ev.result.cancelled
|
|
358
481
|
? "cancelled"
|
|
359
482
|
: "end_turn";
|
|
483
|
+
// Finalise + persist BEFORE emitting `stream_complete`. This
|
|
484
|
+
// ordering matters for any concurrent `session/load` that comes
|
|
485
|
+
// in between `stream_complete` and the prompt's idle signal —
|
|
486
|
+
// the JSONL row should be on disk by the time we tell the
|
|
487
|
+
// client the turn is done.
|
|
488
|
+
const inflight = this.inFlightByTask.get(ev.taskId);
|
|
489
|
+
if (inflight) {
|
|
490
|
+
const record = inflight.finalize(stopReason, ev.completedAt);
|
|
491
|
+
this.store.appendAssistantTurn(sessionId, record);
|
|
492
|
+
this.inFlightByTask.delete(ev.taskId);
|
|
493
|
+
}
|
|
360
494
|
const completeUpdate = {
|
|
361
495
|
sessionUpdate: "stream_complete",
|
|
362
496
|
stopReason,
|
|
@@ -365,13 +499,13 @@ export class SessionManager {
|
|
|
365
499
|
if (ev.error)
|
|
366
500
|
completeUpdate.error = ev.error;
|
|
367
501
|
this.notify(sessionId, completeUpdate);
|
|
368
|
-
this.store.addMessage(sessionId, "system", `Task ${status}`, "status", {
|
|
369
|
-
taskId: ev.taskId,
|
|
370
|
-
status,
|
|
371
|
-
});
|
|
372
502
|
}
|
|
373
503
|
}
|
|
374
504
|
resolveSession(idHint) {
|
|
505
|
+
if (this.fixedSessionId) {
|
|
506
|
+
this.ensureFixedSession();
|
|
507
|
+
return this.sessions.get(this.fixedSessionId);
|
|
508
|
+
}
|
|
375
509
|
if (idHint && this.sessions.has(idHint))
|
|
376
510
|
return this.sessions.get(idHint);
|
|
377
511
|
if (idHint && this.store.sessionExists(idHint)) {
|
|
@@ -384,12 +518,143 @@ export class SessionManager {
|
|
|
384
518
|
const { sessionId } = this.newSession();
|
|
385
519
|
return this.sessions.get(sessionId);
|
|
386
520
|
}
|
|
521
|
+
/** Map any sid the client passes onto the canonical sid in fixed mode. */
|
|
522
|
+
aliasSessionId(sid) {
|
|
523
|
+
return this.fixedSessionId ?? sid;
|
|
524
|
+
}
|
|
525
|
+
/** Materialise the fixed session in-memory + on disk if it isn't yet. */
|
|
526
|
+
ensureFixedSession() {
|
|
527
|
+
if (!this.fixedSessionId)
|
|
528
|
+
return;
|
|
529
|
+
if (!this.sessions.has(this.fixedSessionId)) {
|
|
530
|
+
this.sessions.set(this.fixedSessionId, newSession(this.fixedSessionId));
|
|
531
|
+
}
|
|
532
|
+
if (!this.store.sessionExists(this.fixedSessionId)) {
|
|
533
|
+
this.store.upsertSession(this.fixedSessionId);
|
|
534
|
+
}
|
|
535
|
+
this.activeSessionId = this.fixedSessionId;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Lazily create an in-flight aggregator for events that arrive
|
|
539
|
+
* without a preceding `task:start` (defensive — agent-host always
|
|
540
|
+
* fires start, but tests + alternate adapters may not).
|
|
541
|
+
*/
|
|
542
|
+
ensureInflight(taskId, turnId, messageId) {
|
|
543
|
+
let inflight = this.inFlightByTask.get(taskId);
|
|
544
|
+
if (!inflight) {
|
|
545
|
+
inflight = InFlightTurnAggregator.start({ taskId, turnId, messageId });
|
|
546
|
+
this.inFlightByTask.set(taskId, inflight);
|
|
547
|
+
}
|
|
548
|
+
return inflight;
|
|
549
|
+
}
|
|
550
|
+
findInflightForSession(sessionId) {
|
|
551
|
+
for (const [taskId, sid] of this.taskToSession) {
|
|
552
|
+
if (sid !== sessionId)
|
|
553
|
+
continue;
|
|
554
|
+
const inflight = this.inFlightByTask.get(taskId);
|
|
555
|
+
if (inflight)
|
|
556
|
+
return inflight;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Emit a single persisted record as a `history_turn` envelope.
|
|
562
|
+
*
|
|
563
|
+
* Mirrors beeos-claw `bridge-core.replayAssistantTurnAsStream` —
|
|
564
|
+
* `history_turn` is a stamped-done snapshot of an entire turn that
|
|
565
|
+
* the chat-store reducer renders as a complete bubble (no streaming
|
|
566
|
+
* cursor). For assistant turns, we serialise tool calls into the
|
|
567
|
+
* shape `apply-update.buildToolCalls` accepts.
|
|
568
|
+
*/
|
|
569
|
+
emitHistoryTurn(sessionId, rec) {
|
|
570
|
+
if (rec.type === "user_message") {
|
|
571
|
+
const update = {
|
|
572
|
+
sessionUpdate: "history_turn",
|
|
573
|
+
role: "user",
|
|
574
|
+
content: rec.content,
|
|
575
|
+
timestamp: new Date(rec.ts).toISOString(),
|
|
576
|
+
};
|
|
577
|
+
if (rec.messageId)
|
|
578
|
+
update.messageId = rec.messageId;
|
|
579
|
+
if (rec.turnId)
|
|
580
|
+
update._turnId = rec.turnId;
|
|
581
|
+
this.notify(sessionId, update);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// assistant_turn
|
|
585
|
+
const turn = rec;
|
|
586
|
+
const toolCalls = turn.toolCalls.map((tc) => ({
|
|
587
|
+
id: tc.id,
|
|
588
|
+
name: tc.name,
|
|
589
|
+
arguments: typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args ?? {}),
|
|
590
|
+
status: tc.status,
|
|
591
|
+
...(tc.output !== undefined ? { output: tc.output } : {}),
|
|
592
|
+
isError: tc.status === "failed",
|
|
593
|
+
}));
|
|
594
|
+
const update = {
|
|
595
|
+
sessionUpdate: "history_turn",
|
|
596
|
+
role: "assistant",
|
|
597
|
+
content: turn.text,
|
|
598
|
+
timestamp: new Date(turn.ts).toISOString(),
|
|
599
|
+
messageId: turn.messageId,
|
|
600
|
+
_turnId: turn.turnId,
|
|
601
|
+
};
|
|
602
|
+
if (toolCalls.length > 0)
|
|
603
|
+
update.toolCalls = toolCalls;
|
|
604
|
+
if (turn.error)
|
|
605
|
+
update.metadata = { error: turn.error };
|
|
606
|
+
this.notify(sessionId, update);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Emit the current state of an in-flight turn as streaming-style
|
|
610
|
+
* envelopes WITHOUT the closing `stream_complete`. The client uses
|
|
611
|
+
* the absence of `stream_complete` to recognise the turn is still
|
|
612
|
+
* generating, matching beeos-claw's `emitCurrentStreamSnapshot`.
|
|
613
|
+
*/
|
|
614
|
+
emitInflightSnapshot(sessionId, inflight) {
|
|
615
|
+
const text = inflight.currentText();
|
|
616
|
+
if (text) {
|
|
617
|
+
this.notify(sessionId, {
|
|
618
|
+
sessionUpdate: "agent_message_chunk",
|
|
619
|
+
content: { type: "text", text },
|
|
620
|
+
_turnId: inflight.turnId,
|
|
621
|
+
messageId: inflight.messageId,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
for (const tc of inflight.currentToolCalls()) {
|
|
625
|
+
this.notify(sessionId, {
|
|
626
|
+
sessionUpdate: "tool_call",
|
|
627
|
+
toolCallId: tc.id,
|
|
628
|
+
title: tc.name,
|
|
629
|
+
arguments: JSON.stringify(tc.args),
|
|
630
|
+
status: "in_progress",
|
|
631
|
+
_turnId: inflight.turnId,
|
|
632
|
+
messageId: inflight.messageId,
|
|
633
|
+
});
|
|
634
|
+
if (tc.status !== "in_progress") {
|
|
635
|
+
const update = {
|
|
636
|
+
sessionUpdate: "tool_call_update",
|
|
637
|
+
toolCallId: tc.id,
|
|
638
|
+
status: tc.status,
|
|
639
|
+
_turnId: inflight.turnId,
|
|
640
|
+
messageId: inflight.messageId,
|
|
641
|
+
};
|
|
642
|
+
if (tc.output !== undefined)
|
|
643
|
+
update.output = tc.output;
|
|
644
|
+
this.notify(sessionId, update);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Deliberately do NOT emit `stream_complete` — its absence is the
|
|
648
|
+
// signal the client uses to recognise a still-generating turn.
|
|
649
|
+
}
|
|
387
650
|
notify(sessionId, update) {
|
|
388
651
|
if (!this.notifier)
|
|
389
652
|
return;
|
|
390
653
|
this.notifier.send(sessionId, update);
|
|
391
654
|
}
|
|
392
655
|
evictExpired() {
|
|
656
|
+
if (this.fixedSessionId)
|
|
657
|
+
return;
|
|
393
658
|
const now = Date.now();
|
|
394
659
|
for (const [id, s] of this.sessions) {
|
|
395
660
|
if (now - s.lastActive > this.sessionTtlMs) {
|
|
@@ -417,9 +682,7 @@ export class SessionManager {
|
|
|
417
682
|
* `resource_link` URIs are preserved verbatim on their own line at the
|
|
418
683
|
* end of the merged prompt, so the Resource Resolution Layer's parsers
|
|
419
684
|
* (`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.
|
|
685
|
+
* out-of-band channel.
|
|
423
686
|
*/
|
|
424
687
|
function normalizePrompt(raw) {
|
|
425
688
|
if (typeof raw === "string")
|