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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.0 # 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.0` (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.0"},
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,15 +45,17 @@ 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
55
  | `session/close` | releases the session (its JSONL stays for a later load) |
56
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.
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
 
59
61
  ## Build (bundle and npm form)
package/lib/agent.js CHANGED
@@ -1,7 +1,8 @@
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
6
7
  // session/set_mode per-session tool policy (default | accept_edits)
7
8
  // session/prompt submitInput: deltas -> agent_*_chunk (messageId), durable -> _bma/message
@@ -20,6 +21,8 @@ import { TOOL_SPECS } from "./tools.js";
20
21
  export const VERSION = shellPkg.version;
21
22
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
22
23
  const MEDIA = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
24
+ const HOST_TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
25
+ const KINDS = new Set(["read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"]);
23
26
  export class DshAcpShell {
24
27
  app;
25
28
  client;
@@ -27,12 +30,16 @@ export class DshAcpShell {
27
30
  declared = new Map();
28
31
  adapter;
29
32
  catalog;
33
+ /** This build decodes images (DshAdapter.probeImageInput); without it image prompts are refused. */
34
+ imageInput = false;
35
+ /** The client machine's tools (initialize _meta.bma.hostTools). */
36
+ hostTools = [];
30
37
  constructor(adapter, catalog) {
31
38
  this.adapter = adapter;
32
39
  this.catalog = catalog;
33
40
  this.app = acpAgent({ name: "bma-dsh-acp" })
34
41
  .onConnect((conn) => { this.client = conn.client; })
35
- .onRequest("initialize", () => this.initialize())
42
+ .onRequest("initialize", ({ params }) => this.initialize(params))
36
43
  .onRequest("session/new", ({ params }) => this.open(params, false))
37
44
  .onRequest("session/load", ({ params }) => this.open(params, true))
38
45
  .onRequest("session/set_mode", ({ params }) => this.setMode(params))
@@ -52,9 +59,13 @@ export class DshAcpShell {
52
59
  return { baseUrl: s.model.baseUrl, token: s.model.token, model: s.model.model };
53
60
  },
54
61
  firstChunk: () => { },
62
+ idleRetry: (sid, result) => { process.stderr.write(`bma-dsh-acp: model stream idle before any content (session ${sid}): ${result}\n`); },
55
63
  usage: () => { }, // metered by the client's model proxy
56
64
  });
57
- adapter.registerTools(TOOL_SPECS.map((spec) => shell.tool(spec)));
65
+ shell.registerTools();
66
+ shell.imageInput = await adapter.probeImageInput();
67
+ if (!shell.imageInput)
68
+ 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
69
  return shell;
59
70
  }
60
71
  async dispose() {
@@ -62,13 +73,23 @@ export class DshAcpShell {
62
73
  s.running?.abort();
63
74
  await this.adapter.dispose();
64
75
  }
65
- initialize() {
76
+ registerTools() {
77
+ this.adapter.registerTools([...TOOL_SPECS, ...this.hostTools].map((spec) => this.tool(spec)));
78
+ }
79
+ toolNames() {
80
+ return [...TOOL_NAMES, ...this.hostTools.map((t) => t.name)];
81
+ }
82
+ initialize(params) {
83
+ if (this.sessions.size)
84
+ throw RequestError.invalidRequest(undefined, "initialize after a session was opened");
85
+ this.hostTools = hostToolsMeta(params._meta);
86
+ this.registerTools();
66
87
  return {
67
88
  protocolVersion: PROTOCOL_VERSION,
68
- agentCapabilities: { loadSession: true, promptCapabilities: { image: true, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false } },
89
+ agentCapabilities: { loadSession: true, promptCapabilities: { image: this.imageInput, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false } },
69
90
  agentInfo: { name: "bma-dsh-acp", title: "BMA DSH", version: VERSION },
70
91
  authMethods: [],
71
- _meta: { [META]: { dsh: DSH_VERSION, tools: TOOL_NAMES, streaming: "token", permissionRelay: "full", modes: Object.keys(MODES) } },
92
+ _meta: { [META]: { dsh: DSH_VERSION, tools: TOOL_NAMES, hostTools: this.hostTools.map((t) => t.name), streaming: "token", permissionRelay: "full", modes: Object.keys(MODES) } },
72
93
  };
73
94
  }
74
95
  async open(params, load) {
@@ -86,7 +107,7 @@ export class DshAcpShell {
86
107
  open.model = meta.model; // a reload in the same process: new route, same conversation
87
108
  }
88
109
  else {
89
- const s = { id, model: meta.model, mode: "default", policy: modePolicy("default"), ids: freshIds(), chain: Promise.resolve() };
110
+ const s = { id, model: meta.model, mode: "default", policy: modePolicy("default", this.hostTools), ids: freshIds(), chain: Promise.resolve() };
90
111
  this.sessions.set(id, s); // the route must exist before the first model request
91
112
  const init = { sessionId: id, model: meta.model.model, systemPrompt: meta.systemPrompt ?? "", cwd: params.cwd };
92
113
  try {
@@ -103,7 +124,7 @@ export class DshAcpShell {
103
124
  }
104
125
  }
105
126
  const catalog = [...this.adapter.toolCatalog(id)];
106
- const violation = catalogViolation("dsh", TOOL_NAMES, catalog);
127
+ const violation = catalogViolation("dsh", this.toolNames(), catalog);
107
128
  if (violation) {
108
129
  await this.close(id);
109
130
  throw RequestError.internalError(undefined, violation);
@@ -131,16 +152,18 @@ export class DshAcpShell {
131
152
  throw RequestError.invalidParams(undefined, `unknown mode ${params.modeId}`);
132
153
  s.mode = params.modeId;
133
154
  if (!s.running)
134
- s.policy = modePolicy(s.mode);
155
+ s.policy = modePolicy(s.mode, this.hostTools);
135
156
  return {};
136
157
  }
137
158
  async prompt(params) {
138
159
  const s = this.get(params.sessionId);
139
160
  if (s.running)
140
161
  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 };
162
+ const meta = promptMeta(params._meta, this.toolNames());
163
+ s.policy = { ...modePolicy(s.mode, this.hostTools), ...meta.toolPolicy };
143
164
  const input = { id: meta.inputId ?? randomUUID(), ...promptInput(params.prompt) };
165
+ if (input.images && !this.imageInput)
166
+ throw RequestError.invalidParams(undefined, "image input is unavailable in this build of bma-dsh-acp");
144
167
  const ctl = new AbortController();
145
168
  s.running = ctl;
146
169
  try {
@@ -166,7 +189,7 @@ export class DshAcpShell {
166
189
  }
167
190
  finally {
168
191
  s.running = undefined;
169
- s.policy = modePolicy(s.mode);
192
+ s.policy = modePolicy(s.mode, this.hostTools);
170
193
  s.ids = freshIds();
171
194
  }
172
195
  }
@@ -211,7 +234,7 @@ export class DshAcpShell {
211
234
  async invoke(spec, call) {
212
235
  const s = this.get(call.sessionId);
213
236
  const client = this.client;
214
- const permission = s.policy[spec.name];
237
+ const permission = s.policy[spec.name] ?? "ask";
215
238
  // Let the turn's queued message events go out first (they were produced before this call).
216
239
  await new Promise((resolve) => setImmediate(resolve));
217
240
  s.ids = freshIds();
@@ -247,7 +270,9 @@ export class DshAcpShell {
247
270
  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
271
  if (!result)
249
272
  return finish(aborted());
250
- return finish({ text: String(result.text ?? ""), isError: Boolean(result.isError) });
273
+ const images = (result.images ?? []).filter((i) => MEDIA.has(i.mimeType) && typeof i.data === "string" && i.data)
274
+ .map((i) => ({ mediaType: i.mimeType, data: i.data }));
275
+ return finish({ text: String(result.text ?? ""), isError: Boolean(result.isError), ...(images.length ? { images } : {}) });
251
276
  }
252
277
  catch (err) {
253
278
  if (call.signal.aborted)
@@ -317,7 +342,35 @@ function sessionMeta(raw) {
317
342
  model: { baseUrl: String(model.baseUrl), token: model.token, model: model.model, ...(contextWindow ? { contextWindow } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}) },
318
343
  };
319
344
  }
320
- function promptMeta(raw) {
345
+ function hostToolsMeta(raw) {
346
+ const tools = bmaMeta(raw)?.hostTools;
347
+ if (tools === undefined)
348
+ return [];
349
+ if (!Array.isArray(tools))
350
+ throw RequestError.invalidParams(undefined, "_meta.bma.hostTools must be an array");
351
+ const seen = new Set(TOOL_NAMES);
352
+ return tools.map((t, i) => {
353
+ const at = `_meta.bma.hostTools[${i}]`;
354
+ if (typeof t !== "object" || t === null)
355
+ throw RequestError.invalidParams(undefined, `${at} must be an object`);
356
+ if (typeof t.name !== "string" || !HOST_TOOL_NAME.test(t.name) || seen.has(t.name))
357
+ throw RequestError.invalidParams(undefined, `${at}.name must be a new snake_case tool name`);
358
+ seen.add(t.name);
359
+ if (typeof t.description !== "string" || !t.description)
360
+ throw RequestError.invalidParams(undefined, `${at}.description is required`);
361
+ const p = t.parameters;
362
+ if (typeof p !== "object" || p === null || p.type !== "object")
363
+ throw RequestError.invalidParams(undefined, `${at}.parameters must be an object JSON Schema`);
364
+ if (typeof t.readOnly !== "boolean")
365
+ throw RequestError.invalidParams(undefined, `${at}.readOnly must be a boolean`);
366
+ if (typeof t.kind !== "string" || !KINDS.has(t.kind))
367
+ throw RequestError.invalidParams(undefined, `${at}.kind must be an ACP tool kind`);
368
+ if (t.permission !== undefined && t.permission !== "allow" && t.permission !== "ask")
369
+ throw RequestError.invalidParams(undefined, `${at}.permission must be allow or ask`);
370
+ return { name: t.name, description: t.description, parameters: p, readOnly: t.readOnly, kind: t.kind, ...(t.permission ? { permission: t.permission } : {}) };
371
+ });
372
+ }
373
+ function promptMeta(raw, names) {
321
374
  const meta = bmaMeta(raw) ?? {};
322
375
  const out = {};
323
376
  if (meta.inputId !== undefined) {
@@ -330,7 +383,7 @@ function promptMeta(raw) {
330
383
  if (typeof policy !== "object" || policy === null)
331
384
  throw RequestError.invalidParams(undefined, "_meta.bma.toolPolicy must be an object");
332
385
  for (const [name, value] of Object.entries(policy)) {
333
- if (!TOOL_NAMES.includes(name) || !["allow", "ask", "deny"].includes(value)) {
386
+ if (!names.includes(name) || !["allow", "ask", "deny"].includes(value)) {
334
387
  throw RequestError.invalidParams(undefined, `_meta.bma.toolPolicy.${name} must be allow, ask or deny for a known tool`);
335
388
  }
336
389
  }
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.0",
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.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "esbuild": "0.28.2"