@beeos-ai/bma-dsh-acp 0.1.0 → 0.2.1

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 CHANGED
@@ -7,7 +7,7 @@ It has no DSH dependency of its own (test `pins`), runs the shared conformance s
7
7
  ## Install and run (npm)
8
8
 
9
9
  ```sh
10
- npx --yes @beeos-ai/bma-dsh-acp@0.1.0 # one-off; or: npm install -g @beeos-ai/bma-dsh-acp
10
+ npx --yes @beeos-ai/bma-dsh-acp@0.2.1 # one-off; or: npm install -g @beeos-ai/bma-dsh-acp
11
11
  BMA_DSH_ACP_HOME=$HOME/.bma-dsh-acp bma-dsh-acp # ACP (JSON-RPC, newline-delimited) on stdin/stdout
12
12
  ```
13
13
 
@@ -29,10 +29,10 @@ Using it from the BMA executor SDK (`bma-executor-sdk`, Go `ACPAgent`; the SDK n
29
29
 
30
30
  ```go
31
31
  agent, err := bmaexecutor.NewACPAgent(ctx, bmaexecutor.ACPAgentOptions{
32
- // installed once with `npm install -g @beeos-ai/bma-dsh-acp@0.1.0` (the `bma-dsh-acp` bin of that prefix):
32
+ // installed once with `npm install -g @beeos-ai/bma-dsh-acp@0.2.1` (the `bma-dsh-acp` bin of that prefix):
33
33
  Command: []string{"/usr/local/bin/bma-dsh-acp"},
34
34
  // or fetched on first use (slow first start; the cache keeps it afterwards):
35
- // Command: []string{"/usr/local/bin/npx", "--yes", "@beeos-ai/bma-dsh-acp@0.1.0"},
35
+ // Command: []string{"/usr/local/bin/npx", "--yes", "@beeos-ai/bma-dsh-acp@0.2.1"},
36
36
  StateDir: stateDir, // becomes BMA_DSH_ACP_HOME
37
37
  Core: core,
38
38
  Upstream: bmaexecutor.ACPModelUpstream{BaseURL: "https://api.example.com/v1", Model: "my-model"},
@@ -45,14 +45,16 @@ Standard ACP v1 (`@agentclientprotocol/sdk`), plus BMA additions under the `bma`
45
45
 
46
46
  | ACP | bma-dsh-acp |
47
47
  |---|---|
48
- | `initialize` | `loadSession`, image prompts; `_meta.bma` = `{dsh, tools, streaming: "token", permissionRelay: "full", modes}` |
49
- | `session/new`, `session/load` | `_meta.bma` = `{sessionId?, systemPrompt?, model: {baseUrl, token, model, contextWindow?, maxOutputTokens?}}` (required: no model request without an attributable route). `sessionId` = platform session id = DSH session id. Response `_meta.bma.toolCatalog` (the six CMA tools; anything else is refused, fail-closed). `load` replays the conversation as `session/update`; an unknown session is `-32002` |
48
+ | `initialize` | `loadSession`, image prompts; `_meta.bma` = `{dsh, tools, hostTools, streaming: "token", permissionRelay: "full", modes}`. Request `_meta.bma.hostTools` = `[{name, description, parameters, readOnly, kind, permission?}]`: tools the client's machine adds to the six (for example `computer_observe`, `computer_action`, `ask_user_question`); registered for every session, asked for by `permission` (`ask` by default, `allow`), run by the client like the six |
49
+ | `session/new`, `session/load` | `_meta.bma` = `{sessionId?, systemPrompt?, model: {baseUrl, token, model, contextWindow?, maxOutputTokens?}}` (required: no model request without an attributable route). `sessionId` = platform session id = DSH session id. Response `_meta.bma.toolCatalog` (the six CMA tools plus the declared host tools; anything else is refused, fail-closed). `load` replays the conversation as `session/update`; an unknown session is `-32002` |
50
50
  | `session/set_mode` | `default` (ask before bash/write/edit) or `accept_edits` (ask before bash); read/glob/grep never ask |
51
51
  | `session/prompt` | `_meta.bma` = `{inputId?, toolPolicy?}` (`toolPolicy`: per-tool allow/ask/deny for this prompt, e.g. from turn.start `tool_policy`). Streams `agent_message_chunk` / `agent_thought_chunk` with a `messageId`; the durable text of that message is the notification `_bma/message {sessionId, messageId, kind, text}` |
52
- | tool call | `tool_call` (`kind`, full `rawInput`, `_meta.bma {name, permission}`) → `session/request_permission` (full input; `allow_once` / `reject_once`) when the policy says ask → request `_bma/tools/call {sessionId, toolCallId, name, input}` → `{text, isError}` (the client runs it) → `tool_call_update` completed/failed with the result |
52
+ | tool call | `tool_call` (`kind`, full `rawInput`, `_meta.bma {name, permission}`) → `session/request_permission` (full input; `allow_once` / `reject_once`) when the policy says ask → request `_bma/tools/call {sessionId, toolCallId, name, input}` → `{text, isError, images?: [{mimeType, data}]}` (the client runs it; images are shown to the model after the tool message, as a user message — Chat Completions tool messages carry text only) → `tool_call_update` completed/failed with the result |
53
53
  | `session/cancel` | the turn ends with `stopReason: cancelled`; a pending permission or tool call is abandoned (`$/cancel_request` is sent for it) |
54
54
  | stops | `output_limit_reached` → `stopReason: max_tokens` + `_meta.bma {stopCode, detail}`; `context_too_large`, `model_stream_idle` → JSON-RPC error `-32603` with `data.bma {stopCode, detail}`; other failures → `-32603` |
55
- | `session/close` | releases the session (its JSONL stays for a later load) |
55
+ | `session/close` | releases the session's agent scope (its memory; its JSONL stays, so `session/load` brings the whole conversation back); advertised as `agentCapabilities.sessionCapabilities.close`. A client that keeps many sessions (the executor SDK's `ACPAgent` keeps at most `MaxResidentSessions` and closes the least recently used idle one) is what bounds the memory of this process: it holds every session that was opened and not closed. Closing a session that is not open answers `-32002`, which the ACP SDK treats as closed |
56
+
57
+ Sessions share one DSH kernel in the process; each session is its own agent scope (conversation, tool catalog, model route, permissions): session-level isolation, not one DSH context per session.
56
58
 
57
59
  Environment: `BMA_DSH_ACP_HOME` (state directory, required), `BMA_DSH_ACP_STREAM_IDLE_MS`, `BRAIN_MODEL_CATALOG`. stdout carries ACP only (console output goes to stderr).
58
60
 
package/lib/agent.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // bma-dsh-acp: the shared DSH adapter served as an ACP agent (ADR 0027).
2
2
  //
3
3
  // ACP shared adapter / client
4
- // initialize capabilities + _meta.bma facts (dsh version, tools, streaming, permission relay)
4
+ // initialize capabilities + _meta.bma facts (dsh version, tools, streaming, permission relay);
5
+ // request _meta.bma.hostTools adds the client machine's own tools to the six
5
6
  // session/new | load createSession | resumeSession (+ history replay); _meta.bma carries the model route
7
+ // session/close releases the session's agent scope (memory); its JSONL stays for session/load. Advertised as
8
+ // sessionCapabilities.close, so a client can keep many sessions by closing the least recently used
6
9
  // session/set_mode per-session tool policy (default | accept_edits)
7
10
  // session/prompt submitInput: deltas -> agent_*_chunk (messageId), durable -> _bma/message
8
11
  // tool call tool_call -> [request_permission] -> _bma/tools/call (client runs it) -> tool_call_update
@@ -20,6 +23,8 @@ import { TOOL_SPECS } from "./tools.js";
20
23
  export const VERSION = shellPkg.version;
21
24
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
22
25
  const MEDIA = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
26
+ const HOST_TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
27
+ const KINDS = new Set(["read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"]);
23
28
  export class DshAcpShell {
24
29
  app;
25
30
  client;
@@ -27,12 +32,16 @@ export class DshAcpShell {
27
32
  declared = new Map();
28
33
  adapter;
29
34
  catalog;
35
+ /** This build decodes images (DshAdapter.probeImageInput); without it image prompts are refused. */
36
+ imageInput = false;
37
+ /** The client machine's tools (initialize _meta.bma.hostTools). */
38
+ hostTools = [];
30
39
  constructor(adapter, catalog) {
31
40
  this.adapter = adapter;
32
41
  this.catalog = catalog;
33
42
  this.app = acpAgent({ name: "bma-dsh-acp" })
34
43
  .onConnect((conn) => { this.client = conn.client; })
35
- .onRequest("initialize", () => this.initialize())
44
+ .onRequest("initialize", ({ params }) => this.initialize(params))
36
45
  .onRequest("session/new", ({ params }) => this.open(params, false))
37
46
  .onRequest("session/load", ({ params }) => this.open(params, true))
38
47
  .onRequest("session/set_mode", ({ params }) => this.setMode(params))
@@ -52,9 +61,13 @@ export class DshAcpShell {
52
61
  return { baseUrl: s.model.baseUrl, token: s.model.token, model: s.model.model };
53
62
  },
54
63
  firstChunk: () => { },
64
+ idleRetry: (sid, result) => { process.stderr.write(`bma-dsh-acp: model stream idle before any content (session ${sid}): ${result}\n`); },
55
65
  usage: () => { }, // metered by the client's model proxy
56
66
  });
57
- adapter.registerTools(TOOL_SPECS.map((spec) => shell.tool(spec)));
67
+ shell.registerTools();
68
+ shell.imageInput = await adapter.probeImageInput();
69
+ if (!shell.imageInput)
70
+ process.stderr.write("bma-dsh-acp: image input is unavailable in this build (the image decoder could not be loaded); image prompts are refused\n");
58
71
  return shell;
59
72
  }
60
73
  async dispose() {
@@ -62,13 +75,23 @@ export class DshAcpShell {
62
75
  s.running?.abort();
63
76
  await this.adapter.dispose();
64
77
  }
65
- initialize() {
78
+ registerTools() {
79
+ this.adapter.registerTools([...TOOL_SPECS, ...this.hostTools].map((spec) => this.tool(spec)));
80
+ }
81
+ toolNames() {
82
+ return [...TOOL_NAMES, ...this.hostTools.map((t) => t.name)];
83
+ }
84
+ initialize(params) {
85
+ if (this.sessions.size)
86
+ throw RequestError.invalidRequest(undefined, "initialize after a session was opened");
87
+ this.hostTools = hostToolsMeta(params._meta);
88
+ this.registerTools();
66
89
  return {
67
90
  protocolVersion: PROTOCOL_VERSION,
68
- agentCapabilities: { loadSession: true, promptCapabilities: { image: true, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false } },
91
+ agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} }, promptCapabilities: { image: this.imageInput, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false } },
69
92
  agentInfo: { name: "bma-dsh-acp", title: "BMA DSH", version: VERSION },
70
93
  authMethods: [],
71
- _meta: { [META]: { dsh: DSH_VERSION, tools: TOOL_NAMES, streaming: "token", permissionRelay: "full", modes: Object.keys(MODES) } },
94
+ _meta: { [META]: { dsh: DSH_VERSION, tools: TOOL_NAMES, hostTools: this.hostTools.map((t) => t.name), streaming: "token", permissionRelay: "full", modes: Object.keys(MODES) } },
72
95
  };
73
96
  }
74
97
  async open(params, load) {
@@ -86,7 +109,7 @@ export class DshAcpShell {
86
109
  open.model = meta.model; // a reload in the same process: new route, same conversation
87
110
  }
88
111
  else {
89
- const s = { id, model: meta.model, mode: "default", policy: modePolicy("default"), ids: freshIds(), chain: Promise.resolve() };
112
+ const s = { id, model: meta.model, mode: "default", policy: modePolicy("default", this.hostTools), ids: freshIds(), chain: Promise.resolve() };
90
113
  this.sessions.set(id, s); // the route must exist before the first model request
91
114
  const init = { sessionId: id, model: meta.model.model, systemPrompt: meta.systemPrompt ?? "", cwd: params.cwd };
92
115
  try {
@@ -103,7 +126,7 @@ export class DshAcpShell {
103
126
  }
104
127
  }
105
128
  const catalog = [...this.adapter.toolCatalog(id)];
106
- const violation = catalogViolation("dsh", TOOL_NAMES, catalog);
129
+ const violation = catalogViolation("dsh", this.toolNames(), catalog);
107
130
  if (violation) {
108
131
  await this.close(id);
109
132
  throw RequestError.internalError(undefined, violation);
@@ -131,16 +154,18 @@ export class DshAcpShell {
131
154
  throw RequestError.invalidParams(undefined, `unknown mode ${params.modeId}`);
132
155
  s.mode = params.modeId;
133
156
  if (!s.running)
134
- s.policy = modePolicy(s.mode);
157
+ s.policy = modePolicy(s.mode, this.hostTools);
135
158
  return {};
136
159
  }
137
160
  async prompt(params) {
138
161
  const s = this.get(params.sessionId);
139
162
  if (s.running)
140
163
  throw RequestError.invalidRequest(undefined, "a prompt is already running in this session");
141
- const meta = promptMeta(params._meta);
142
- s.policy = { ...modePolicy(s.mode), ...meta.toolPolicy };
164
+ const meta = promptMeta(params._meta, this.toolNames());
165
+ s.policy = { ...modePolicy(s.mode, this.hostTools), ...meta.toolPolicy };
143
166
  const input = { id: meta.inputId ?? randomUUID(), ...promptInput(params.prompt) };
167
+ if (input.images && !this.imageInput)
168
+ throw RequestError.invalidParams(undefined, "image input is unavailable in this build of bma-dsh-acp");
144
169
  const ctl = new AbortController();
145
170
  s.running = ctl;
146
171
  try {
@@ -166,7 +191,7 @@ export class DshAcpShell {
166
191
  }
167
192
  finally {
168
193
  s.running = undefined;
169
- s.policy = modePolicy(s.mode);
194
+ s.policy = modePolicy(s.mode, this.hostTools);
170
195
  s.ids = freshIds();
171
196
  }
172
197
  }
@@ -211,7 +236,7 @@ export class DshAcpShell {
211
236
  async invoke(spec, call) {
212
237
  const s = this.get(call.sessionId);
213
238
  const client = this.client;
214
- const permission = s.policy[spec.name];
239
+ const permission = s.policy[spec.name] ?? "ask";
215
240
  // Let the turn's queued message events go out first (they were produced before this call).
216
241
  await new Promise((resolve) => setImmediate(resolve));
217
242
  s.ids = freshIds();
@@ -247,7 +272,9 @@ export class DshAcpShell {
247
272
  const result = await untilAborted(client.request(TOOLS_CALL, { sessionId: s.id, toolCallId: call.callId, name: spec.name, input: call.input }, { cancellationSignal: call.signal }), call.signal);
248
273
  if (!result)
249
274
  return finish(aborted());
250
- return finish({ text: String(result.text ?? ""), isError: Boolean(result.isError) });
275
+ const images = (result.images ?? []).filter((i) => MEDIA.has(i.mimeType) && typeof i.data === "string" && i.data)
276
+ .map((i) => ({ mediaType: i.mimeType, data: i.data }));
277
+ return finish({ text: String(result.text ?? ""), isError: Boolean(result.isError), ...(images.length ? { images } : {}) });
251
278
  }
252
279
  catch (err) {
253
280
  if (call.signal.aborted)
@@ -317,7 +344,35 @@ function sessionMeta(raw) {
317
344
  model: { baseUrl: String(model.baseUrl), token: model.token, model: model.model, ...(contextWindow ? { contextWindow } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}) },
318
345
  };
319
346
  }
320
- function promptMeta(raw) {
347
+ function hostToolsMeta(raw) {
348
+ const tools = bmaMeta(raw)?.hostTools;
349
+ if (tools === undefined)
350
+ return [];
351
+ if (!Array.isArray(tools))
352
+ throw RequestError.invalidParams(undefined, "_meta.bma.hostTools must be an array");
353
+ const seen = new Set(TOOL_NAMES);
354
+ return tools.map((t, i) => {
355
+ const at = `_meta.bma.hostTools[${i}]`;
356
+ if (typeof t !== "object" || t === null)
357
+ throw RequestError.invalidParams(undefined, `${at} must be an object`);
358
+ if (typeof t.name !== "string" || !HOST_TOOL_NAME.test(t.name) || seen.has(t.name))
359
+ throw RequestError.invalidParams(undefined, `${at}.name must be a new snake_case tool name`);
360
+ seen.add(t.name);
361
+ if (typeof t.description !== "string" || !t.description)
362
+ throw RequestError.invalidParams(undefined, `${at}.description is required`);
363
+ const p = t.parameters;
364
+ if (typeof p !== "object" || p === null || p.type !== "object")
365
+ throw RequestError.invalidParams(undefined, `${at}.parameters must be an object JSON Schema`);
366
+ if (typeof t.readOnly !== "boolean")
367
+ throw RequestError.invalidParams(undefined, `${at}.readOnly must be a boolean`);
368
+ if (typeof t.kind !== "string" || !KINDS.has(t.kind))
369
+ throw RequestError.invalidParams(undefined, `${at}.kind must be an ACP tool kind`);
370
+ if (t.permission !== undefined && t.permission !== "allow" && t.permission !== "ask")
371
+ throw RequestError.invalidParams(undefined, `${at}.permission must be allow or ask`);
372
+ return { name: t.name, description: t.description, parameters: p, readOnly: t.readOnly, kind: t.kind, ...(t.permission ? { permission: t.permission } : {}) };
373
+ });
374
+ }
375
+ function promptMeta(raw, names) {
321
376
  const meta = bmaMeta(raw) ?? {};
322
377
  const out = {};
323
378
  if (meta.inputId !== undefined) {
@@ -330,7 +385,7 @@ function promptMeta(raw) {
330
385
  if (typeof policy !== "object" || policy === null)
331
386
  throw RequestError.invalidParams(undefined, "_meta.bma.toolPolicy must be an object");
332
387
  for (const [name, value] of Object.entries(policy)) {
333
- if (!TOOL_NAMES.includes(name) || !["allow", "ask", "deny"].includes(value)) {
388
+ if (!names.includes(name) || !["allow", "ask", "deny"].includes(value)) {
334
389
  throw RequestError.invalidParams(undefined, `_meta.bma.toolPolicy.${name} must be allow, ask or deny for a known tool`);
335
390
  }
336
391
  }
package/lib/protocol.js CHANGED
@@ -7,8 +7,11 @@ export const MODES = {
7
7
  default: { name: "Ask before changes", description: "Ask before bash, write and edit; read, glob and grep run without asking." },
8
8
  accept_edits: { name: "Accept edits", description: "Ask before bash; write, edit and the read-only tools run without asking." },
9
9
  };
10
- export function modePolicy(mode) {
10
+ export function modePolicy(mode, hostTools = []) {
11
11
  const ask = "ask";
12
12
  const edits = mode === "accept_edits" ? "allow" : "ask";
13
- return { bash: ask, write: edits, edit: edits, read: "allow", glob: "allow", grep: "allow" };
13
+ const policy = { bash: ask, write: edits, edit: edits, read: "allow", glob: "allow", grep: "allow" };
14
+ for (const tool of hostTools)
15
+ policy[tool.name] = tool.permission ?? "ask";
16
+ return policy;
14
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beeos-ai/bma-dsh-acp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "bma-dsh-acp: the shared DSH adapter served as an Agent Client Protocol (stdio) agent, driven by the BMA executor's ACP client.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -10,7 +10,7 @@
10
10
  "agent-client-protocol",
11
11
  "managed-agents"
12
12
  ],
13
- "homepage": "https://bma.beeos.ai",
13
+ "homepage": "https://staging.bma.beeos.ai",
14
14
  "repository": {
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/beeos-ai/beeos-managed-agents.git",
@@ -27,7 +27,7 @@
27
27
  "dependencies": {
28
28
  "@agentclientprotocol/sdk": "2.2.0",
29
29
  "zod": "4.6.7",
30
- "@beeos-ai/bma-dsh-adapter": "0.1.0"
30
+ "@beeos-ai/bma-dsh-adapter": "0.2.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "esbuild": "0.28.2"