@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.
@@ -47,6 +47,18 @@ import { Type } from "@sinclair/typebox";
47
47
  import { getPiExtensionContext } from "../../src/pi-context.js";
48
48
  import type { Shortcut, ShortcutResult } from "../../src/shortcuts/index.js";
49
49
 
50
+ /**
51
+ * Server-side floor for `mobile_use` step budget.
52
+ *
53
+ * The outer orchestrator LLM frequently misjudges ADB / Android tasks as
54
+ * "simple actions" and emits `max_steps: 5..15`, which the inner
55
+ * `Agent.run` loop blows through before the task can finish — surfacing as
56
+ * "Reached max steps (N)". Clamping upward at the tool boundary protects
57
+ * the user without preventing the LLM from explicitly asking for more
58
+ * steps when it judges a flow as complex.
59
+ */
60
+ const MIN_MOBILE_USE_STEPS = 30;
61
+
50
62
  export default function (pi: ExtensionAPI) {
51
63
  // ── mobile_use ─────────────────────────────────────────────────────────────
52
64
  pi.registerTool({
@@ -57,12 +69,12 @@ export default function (pi: ExtensionAPI) {
57
69
  "A VLM runs its own screenshot→reason→act loop until it finishes or hits max-steps. " +
58
70
  "Pass a full, concrete instruction with app-native UI text. " +
59
71
  "For long text (especially Chinese), embed it verbatim and note '原样输入、不要翻译'. " +
60
- "One call runs 10–30 steps autonomously; do NOT call repeatedly for the same task.",
72
+ "One call runs 20–50 steps autonomously; do NOT call repeatedly for the same task.",
61
73
  promptSnippet: "mobile_use(task): drive Android phone via VLM screenshot→reason→act loop",
62
74
  parameters: Type.Object({
63
75
  task: Type.String({ description: "Full natural-language instruction in Chinese or English" }),
64
76
  max_steps: Type.Integer({
65
- description: "Maximum VLM steps for this call. Required. Typical range 10-30 depending on task complexity.",
77
+ description: "Maximum VLM steps for this call. Required. Typical range 20-50 depending on task complexity.",
66
78
  minimum: 1,
67
79
  maximum: 100,
68
80
  }),
@@ -72,7 +84,16 @@ export default function (pi: ExtensionAPI) {
72
84
  if (signal?.aborted) throw new Error("aborted");
73
85
 
74
86
  const ctx = getPiExtensionContext();
75
- const result = await ctx.runMobileUse(params.task, params.max_steps, signal ?? undefined);
87
+ const requested = params.max_steps;
88
+ const effective = Math.max(MIN_MOBILE_USE_STEPS, requested);
89
+ if (effective !== requested) {
90
+ const log = (await import("../../src/logger/index.js")).getLogger();
91
+ log.warn(
92
+ { component: "mobile_use", requested, effective, floor: MIN_MOBILE_USE_STEPS },
93
+ "mobile_use.max_steps clamped up to floor",
94
+ );
95
+ }
96
+ const result = await ctx.runMobileUse(params.task, effective, signal ?? undefined);
76
97
  return { content: [{ type: "text", text: result }], details: null };
77
98
  },
78
99
  });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * In-flight assistant turn aggregator.
3
+ *
4
+ * A single ACP `session/prompt` produces a stream of host events
5
+ * (`task:text` deltas, `task:tool` start/update/end, etc.). Persisting
6
+ * each delta as a separate row produces unusable transcripts on reload
7
+ * — the resulting bubble is fragmented into dozens of tiny rows with
8
+ * no clean way to reconstruct what the agent actually said. To match
9
+ * OpenClaw's "one JSONL line = one complete assistant turn" model, we
10
+ * accumulate the delta stream in memory here and only commit a single
11
+ * `AssistantTurnRecord` when the turn terminates.
12
+ *
13
+ * Lifecycle:
14
+ * - `start(...)` — called from `task:start`, allocates a buffer.
15
+ * - `appendText(delta)` / `noteToolStart` / `noteToolUpdate` /
16
+ * `noteToolEnd` — accumulate during the turn.
17
+ * - `noteFatalNotice(text)` — capture terminal error text so the
18
+ * persisted turn carries an `error` field even if the agent's
19
+ * final reply is empty.
20
+ * - `finalize(stopReason)` — return an immutable
21
+ * `AssistantTurnRecord` ready to be appended to JSONL and emitted
22
+ * to the client. Caller resets the buffer afterwards.
23
+ *
24
+ * This module is pure data + state machine — no IO, no notifications.
25
+ * SessionManager owns the IO + dispatch side.
26
+ */
27
+ /** Final status of a single tool invocation inside one turn. */
28
+ export type ToolCallStatus = "in_progress" | "completed" | "failed";
29
+ /** Stop reason mirrored from beeos-claw's three-valued enum. */
30
+ export type StopReason = "end_turn" | "cancelled" | "error";
31
+ export interface InFlightToolCall {
32
+ id: string;
33
+ name: string;
34
+ args: unknown;
35
+ status: ToolCallStatus;
36
+ /** Latest output observed (overwritten on each `update` and `end`). */
37
+ output?: string;
38
+ startedAt: number;
39
+ endedAt?: number;
40
+ }
41
+ /** Persisted shape — what gets written to JSONL on `task:end`. */
42
+ export interface PersistedToolCall {
43
+ id: string;
44
+ name: string;
45
+ args: unknown;
46
+ status: ToolCallStatus;
47
+ output?: string;
48
+ startedAt: number;
49
+ endedAt?: number;
50
+ }
51
+ export interface AssistantTurnRecord {
52
+ /** Stable `messageId` stamped on every chunk during this turn. */
53
+ messageId: string;
54
+ /** Stable `_turnId` stamped on every chunk during this turn. */
55
+ turnId: string;
56
+ /** Wall-clock time when the turn started (epoch ms). */
57
+ startedAt: number;
58
+ /** Wall-clock time when the turn finalized (epoch ms). */
59
+ completedAt: number;
60
+ /** Concatenated assistant reply (sum of every `task:text` delta). */
61
+ text: string;
62
+ /** Tool calls in invocation order (insertion order is preserved). */
63
+ toolCalls: PersistedToolCall[];
64
+ /** Stop reason at finalize time. */
65
+ stopReason: StopReason;
66
+ /** User-facing error text when the turn terminated abnormally. */
67
+ error?: string;
68
+ }
69
+ /**
70
+ * Mutable in-flight buffer. Construct once per turn via
71
+ * `InFlightTurnAggregator.start(...)`, accumulate, then `finalize()`.
72
+ */
73
+ export declare class InFlightTurnAggregator {
74
+ readonly taskId: string;
75
+ readonly turnId: string;
76
+ readonly messageId: string;
77
+ readonly startedAt: number;
78
+ private text;
79
+ private readonly toolCalls;
80
+ /** Insertion order tracker — `Map` already preserves it but we keep
81
+ * an explicit list for tests / iteration clarity. */
82
+ private readonly toolOrder;
83
+ private errorText;
84
+ constructor(taskId: string, turnId: string, messageId: string, startedAt?: number);
85
+ /** Convenience constructor mirroring SessionManager call sites. */
86
+ static start(opts: {
87
+ taskId: string;
88
+ turnId: string;
89
+ messageId: string;
90
+ now?: number;
91
+ }): InFlightTurnAggregator;
92
+ /** Append a text delta to the in-flight reply. Empty deltas are no-ops. */
93
+ appendText(delta: string): void;
94
+ /** Read-only snapshot of the accumulated text — used by load-replay. */
95
+ currentText(): string;
96
+ /** Read-only ordered list of tool calls — used by load-replay. */
97
+ currentToolCalls(): readonly InFlightToolCall[];
98
+ noteToolStart(opts: {
99
+ id: string;
100
+ name: string;
101
+ args: unknown;
102
+ now?: number;
103
+ }): void;
104
+ noteToolUpdate(opts: {
105
+ id: string;
106
+ output?: string;
107
+ }): void;
108
+ noteToolEnd(opts: {
109
+ id: string;
110
+ output?: string;
111
+ isError?: boolean;
112
+ now?: number;
113
+ }): void;
114
+ /** Capture terminal error text from a `task:notice level=error`. */
115
+ noteFatalNotice(text: string): void;
116
+ /** Whether anything would actually be persisted on finalize. */
117
+ isEmpty(): boolean;
118
+ /** Produce the immutable record ready for JSONL append + replay. */
119
+ finalize(stopReason: StopReason, now?: number): AssistantTurnRecord;
120
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * In-flight assistant turn aggregator.
3
+ *
4
+ * A single ACP `session/prompt` produces a stream of host events
5
+ * (`task:text` deltas, `task:tool` start/update/end, etc.). Persisting
6
+ * each delta as a separate row produces unusable transcripts on reload
7
+ * — the resulting bubble is fragmented into dozens of tiny rows with
8
+ * no clean way to reconstruct what the agent actually said. To match
9
+ * OpenClaw's "one JSONL line = one complete assistant turn" model, we
10
+ * accumulate the delta stream in memory here and only commit a single
11
+ * `AssistantTurnRecord` when the turn terminates.
12
+ *
13
+ * Lifecycle:
14
+ * - `start(...)` — called from `task:start`, allocates a buffer.
15
+ * - `appendText(delta)` / `noteToolStart` / `noteToolUpdate` /
16
+ * `noteToolEnd` — accumulate during the turn.
17
+ * - `noteFatalNotice(text)` — capture terminal error text so the
18
+ * persisted turn carries an `error` field even if the agent's
19
+ * final reply is empty.
20
+ * - `finalize(stopReason)` — return an immutable
21
+ * `AssistantTurnRecord` ready to be appended to JSONL and emitted
22
+ * to the client. Caller resets the buffer afterwards.
23
+ *
24
+ * This module is pure data + state machine — no IO, no notifications.
25
+ * SessionManager owns the IO + dispatch side.
26
+ */
27
+ /**
28
+ * Mutable in-flight buffer. Construct once per turn via
29
+ * `InFlightTurnAggregator.start(...)`, accumulate, then `finalize()`.
30
+ */
31
+ export class InFlightTurnAggregator {
32
+ taskId;
33
+ turnId;
34
+ messageId;
35
+ startedAt;
36
+ text = "";
37
+ toolCalls = new Map();
38
+ /** Insertion order tracker — `Map` already preserves it but we keep
39
+ * an explicit list for tests / iteration clarity. */
40
+ toolOrder = [];
41
+ errorText;
42
+ constructor(taskId, turnId, messageId, startedAt = Date.now()) {
43
+ this.taskId = taskId;
44
+ this.turnId = turnId;
45
+ this.messageId = messageId;
46
+ this.startedAt = startedAt;
47
+ }
48
+ /** Convenience constructor mirroring SessionManager call sites. */
49
+ static start(opts) {
50
+ return new InFlightTurnAggregator(opts.taskId, opts.turnId, opts.messageId, opts.now ?? Date.now());
51
+ }
52
+ /** Append a text delta to the in-flight reply. Empty deltas are no-ops. */
53
+ appendText(delta) {
54
+ if (!delta)
55
+ return;
56
+ this.text += delta;
57
+ }
58
+ /** Read-only snapshot of the accumulated text — used by load-replay. */
59
+ currentText() {
60
+ return this.text;
61
+ }
62
+ /** Read-only ordered list of tool calls — used by load-replay. */
63
+ currentToolCalls() {
64
+ return this.toolOrder.map((id) => this.toolCalls.get(id)).filter(Boolean);
65
+ }
66
+ noteToolStart(opts) {
67
+ if (this.toolCalls.has(opts.id)) {
68
+ // Re-issued start (rare — agent core sometimes replays). Reset
69
+ // to the new args/name but preserve insertion order.
70
+ this.toolCalls.set(opts.id, {
71
+ id: opts.id,
72
+ name: opts.name,
73
+ args: opts.args,
74
+ status: "in_progress",
75
+ startedAt: opts.now ?? Date.now(),
76
+ });
77
+ return;
78
+ }
79
+ this.toolCalls.set(opts.id, {
80
+ id: opts.id,
81
+ name: opts.name,
82
+ args: opts.args,
83
+ status: "in_progress",
84
+ startedAt: opts.now ?? Date.now(),
85
+ });
86
+ this.toolOrder.push(opts.id);
87
+ }
88
+ noteToolUpdate(opts) {
89
+ const existing = this.toolCalls.get(opts.id);
90
+ if (!existing)
91
+ return;
92
+ if (opts.output !== undefined)
93
+ existing.output = opts.output;
94
+ // status stays "in_progress" — terminal status comes via noteToolEnd.
95
+ }
96
+ noteToolEnd(opts) {
97
+ const existing = this.toolCalls.get(opts.id);
98
+ if (!existing) {
99
+ // End-without-start (some adapters skip the start hook). Synthesise a
100
+ // record so we don't lose the terminal state.
101
+ this.toolCalls.set(opts.id, {
102
+ id: opts.id,
103
+ name: opts.id,
104
+ args: {},
105
+ status: opts.isError ? "failed" : "completed",
106
+ output: opts.output,
107
+ startedAt: opts.now ?? Date.now(),
108
+ endedAt: opts.now ?? Date.now(),
109
+ });
110
+ this.toolOrder.push(opts.id);
111
+ return;
112
+ }
113
+ existing.status = opts.isError ? "failed" : "completed";
114
+ if (opts.output !== undefined)
115
+ existing.output = opts.output;
116
+ existing.endedAt = opts.now ?? Date.now();
117
+ }
118
+ /** Capture terminal error text from a `task:notice level=error`. */
119
+ noteFatalNotice(text) {
120
+ if (text)
121
+ this.errorText = text;
122
+ }
123
+ /** Whether anything would actually be persisted on finalize. */
124
+ isEmpty() {
125
+ return this.text.length === 0 && this.toolOrder.length === 0 && !this.errorText;
126
+ }
127
+ /** Produce the immutable record ready for JSONL append + replay. */
128
+ finalize(stopReason, now = Date.now()) {
129
+ const toolCalls = this.toolOrder.map((id) => {
130
+ const tc = this.toolCalls.get(id);
131
+ const out = {
132
+ id: tc.id,
133
+ name: tc.name,
134
+ args: tc.args,
135
+ status: tc.status,
136
+ startedAt: tc.startedAt,
137
+ };
138
+ if (tc.output !== undefined)
139
+ out.output = tc.output;
140
+ if (tc.endedAt !== undefined)
141
+ out.endedAt = tc.endedAt;
142
+ return out;
143
+ });
144
+ const record = {
145
+ messageId: this.messageId,
146
+ turnId: this.turnId,
147
+ startedAt: this.startedAt,
148
+ completedAt: now,
149
+ text: this.text,
150
+ toolCalls,
151
+ stopReason,
152
+ };
153
+ if (this.errorText)
154
+ record.error = this.errorText;
155
+ return record;
156
+ }
157
+ }
158
+ //# sourceMappingURL=in-flight-turn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-flight-turn.js","sourceRoot":"","sources":["../../src/app/in-flight-turn.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Append-only JSONL persistence layer for `MessageStore`.
3
+ *
4
+ * Layout (one file per session, plus one metadata file):
5
+ *
6
+ * ${BEEOS_HOME ?? ~/.beeos}/device-agent/sessions/
7
+ * ├── <sid>.jsonl # append-only record log
8
+ * └── <sid>.meta.json # { sessionId, title, createdAt, updatedAt }
9
+ *
10
+ * Each JSONL line is a serialised `PersistedRecord` (see message-store.ts):
11
+ *
12
+ * {"v":1,"type":"user_message","id":"...","ts":...,"content":"...","turnId":"...","messageId":"..."}
13
+ * {"v":1,"type":"assistant_turn","id":"...","ts":...,"messageId":"...","turnId":"...",
14
+ * "startedAt":...,"completedAt":...,"text":"...","toolCalls":[...],"stopReason":"...","error":"..."}
15
+ *
16
+ * IO strategy:
17
+ *
18
+ * - Appends use `fs.writeSync` against a long-lived FD opened in
19
+ * append mode (`O_APPEND`). Sync IO sidesteps the buffer-flush
20
+ * race that bites WriteStream-based rotation (`clearSession`
21
+ * followed immediately by a new append). The blocking cost is
22
+ * negligible at our message rate (1-2 records per ~5-10s task).
23
+ * - `O_APPEND` guarantees every kernel write seeks to EOF
24
+ * atomically, so concurrent appenders on the same file (rare —
25
+ * would only happen if two device-agent siblings shared a
26
+ * sessions dir) never interleave a single line.
27
+ *
28
+ * Crash semantics:
29
+ *
30
+ * - Each write hits disk before the call returns; finalised turns
31
+ * survive an unclean shutdown. The user-selected `memory_only`
32
+ * in-flight policy means a turn that hadn't yet reached `task:end`
33
+ * is lost — that's expected.
34
+ * - Reads tolerate partial / corrupt tail lines (the writer might
35
+ * have died mid-line on a previous run) by skipping any line that
36
+ * fails to parse. Same convention as beeos-claw's
37
+ * `LocalSessionHistory.readEntries`.
38
+ */
39
+ import { MessageStore, type MessageStoreOptions, type PersistedAssistantTurn, type UserMessageRecord } from "./message-store.js";
40
+ import type { AssistantTurnRecord } from "./in-flight-turn.js";
41
+ export interface JsonlMessageStoreOptions extends MessageStoreOptions {
42
+ /** Directory where `<sid>.jsonl` and `<sid>.meta.json` live. */
43
+ sessionsDir: string;
44
+ /**
45
+ * Specific sids to preload eagerly. Optional — when omitted, the
46
+ * store scans `sessionsDir` for every `*.jsonl` file. Production
47
+ * callers (single-session device-agent) pass the fixed sid here so
48
+ * `open` is O(1) regardless of how many other sids share the dir.
49
+ */
50
+ preloadSids?: readonly string[];
51
+ /** Logger — only used to warn on parse failures. Optional. */
52
+ logger?: {
53
+ warn: (...args: unknown[]) => void;
54
+ };
55
+ }
56
+ /**
57
+ * Persistent variant of `MessageStore` — every append is mirrored to a
58
+ * per-session JSONL log, every metadata change debounced into
59
+ * `<sid>.meta.json`.
60
+ *
61
+ * Construct via `JsonlMessageStore.open(...)` so the on-disk state can
62
+ * be loaded into memory before any reads happen.
63
+ */
64
+ export declare class JsonlMessageStore extends MessageStore {
65
+ private readonly sessionsDir;
66
+ private readonly logger?;
67
+ /** Lazily-opened append-mode FDs keyed by sessionId. */
68
+ private readonly fds;
69
+ private readonly metaTimers;
70
+ private closed;
71
+ private constructor();
72
+ /** Construct and load on-disk state into memory. */
73
+ static open(opts: JsonlMessageStoreOptions): Promise<JsonlMessageStore>;
74
+ /** Path of the JSONL transcript for a given sid. */
75
+ private sessionFile;
76
+ /** Path of the metadata file for a given sid. */
77
+ private metaFile;
78
+ /** Discover every sid present on disk by scanning `*.jsonl` files. */
79
+ private discoverSids;
80
+ /**
81
+ * Read the JSONL for a sid into memory. Skips rows that fail to
82
+ * parse (truncated tail line on a previous crash, schema mismatch
83
+ * from a future version, etc.) so a single bad row doesn't sink the
84
+ * whole session.
85
+ */
86
+ private preloadSession;
87
+ private readMeta;
88
+ private getOrCreateFd;
89
+ appendUserMessage(sessionId: string, content: string, opts?: {
90
+ turnId?: string;
91
+ messageId?: string;
92
+ now?: number;
93
+ }): UserMessageRecord;
94
+ appendAssistantTurn(sessionId: string, turn: AssistantTurnRecord, opts?: {
95
+ now?: number;
96
+ }): PersistedAssistantTurn;
97
+ upsertSession(sessionId: string, title?: string): void;
98
+ renameSession(sessionId: string, title: string): void;
99
+ clearSession(sessionId: string): void;
100
+ deleteSession(sessionId: string): void;
101
+ /** Flush every pending meta write and close every open FD. */
102
+ close(): Promise<void>;
103
+ private writeJsonlLine;
104
+ private closeFd;
105
+ private rotateStream;
106
+ private scheduleMetaWrite;
107
+ private snapshotMeta;
108
+ private writeMetaNow;
109
+ }