acpreact 1.0.7 → 1.1.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
@@ -1,15 +1,27 @@
1
1
  # acpreact - ACP SDK
2
2
 
3
- A lightweight SDK for setting up tools and managing ACP protocol communication. Allows opencode and kilo CLI to call registered tools via the ACP protocol.
3
+ A lightweight SDK for registering tools and running them via kilo CLI, opencode, or gemini. Supports multi-service fallback with automatic rate-limit detection, per-provider cooldowns, and transparent failover across a service stack.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **ACPProtocol**: Core ACP protocol implementation with JSON-RPC 2.0 support
8
8
  - **Tool Registration**: Register custom tools with descriptions and input schemas
9
9
  - **Tool Whitelist**: Built-in security model for controlling tool access
10
- - **Tool Execution**: Execute whitelisted tools with validation
10
+ - **Tool Execution**: Execute whitelisted tools with validation and logging
11
+ - **CLI Integration**: Works with kilo CLI, opencode, and gemini via `process()` method
12
+ - **Multi-Service Fallback**: Automatically falls back through a service stack on rate limits
13
+ - **Rate-Limit Detection**: Detects 429, quota errors, and RESOURCE_EXHAUSTED per provider
11
14
  - **ES Module**: Pure ES modules, no build step required
12
15
 
16
+ ## Prerequisites
17
+
18
+ Install kilo CLI and/or opencode before using `process()`:
19
+
20
+ ```bash
21
+ npm install -g @kilocode/cli # for kilo
22
+ npm install -g opencode-ai # for opencode
23
+ ```
24
+
13
25
  ## Installation
14
26
 
15
27
  ```bash
@@ -18,52 +30,116 @@ npm install acpreact
18
30
 
19
31
  ## Quick Start
20
32
 
21
- ### Creating an ACP Server with Custom Tools
33
+ ### Register Tools and Process with kilo CLI
22
34
 
23
35
  ```javascript
24
36
  import { ACPProtocol } from 'acpreact';
25
37
 
26
- const acp = new ACPProtocol();
38
+ const acp = new ACPProtocol('You are a calculator assistant. Use the add tool when asked to add numbers.');
27
39
 
28
- // Register a custom tool
29
40
  acp.registerTool(
30
- 'weather',
31
- 'Get weather information for a location',
41
+ 'add',
42
+ 'Add two numbers together',
32
43
  {
33
44
  type: 'object',
34
45
  properties: {
35
- location: { type: 'string', description: 'City name' }
46
+ a: { type: 'number', description: 'First number' },
47
+ b: { type: 'number', description: 'Second number' }
36
48
  },
37
- required: ['location']
49
+ required: ['a', 'b']
38
50
  },
39
- async (params) => {
40
- return {
41
- location: params.location,
42
- temperature: 72,
43
- condition: 'sunny'
44
- };
45
- }
51
+ async (params) => ({ sum: params.a + params.b })
46
52
  );
47
53
 
48
- // Initialize protocol response
49
- const response = acp.createInitializeResponse();
54
+ const result = await acp.process('What is 15 + 27? Use the add tool.', { cli: 'kilo' });
55
+ console.log(result.text); // human-readable text response
56
+ console.log(result.toolCalls); // [{ tool: 'add', result: { sum: 42 } }]
57
+ console.log(result.logs); // tool call audit log
58
+ ```
59
+
60
+ ### Using opencode
50
61
 
51
- // Execute tool
52
- const result = await acp.callTool('weather', { location: 'San Francisco' });
53
- console.log(result);
62
+ ```javascript
63
+ const result = await acp.process('What is 15 + 27? Use the add tool.', { cli: 'opencode' });
54
64
  ```
55
65
 
56
- ### Using System Instructions
66
+ ### Multi-Service Fallback
57
67
 
58
- Pass a system instruction to the ACPProtocol constructor. The instruction will be included in the initialization response and communicated to opencode or kilo CLI:
68
+ Pass a `services` array to the constructor to enable automatic fallback across providers. On rate limits, the engine marks the current service unavailable and continues to the next:
59
69
 
60
70
  ```javascript
61
71
  import { ACPProtocol } from 'acpreact';
62
72
 
63
- const instruction = 'You are a helpful weather assistant. Always provide temperature in Fahrenheit.';
64
- const acp = new ACPProtocol(instruction);
73
+ const acp = new ACPProtocol('You are a calculator.', [
74
+ { cli: 'kilo' },
75
+ { cli: 'opencode' },
76
+ { cli: 'gemini' },
77
+ ]);
78
+
79
+ acp.registerTool('add', 'Add two numbers', { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }, async ({ a, b }) => ({ sum: a + b }));
80
+
81
+ const result = await acp.process('What is 15 + 27? Use the add tool.');
82
+ ```
83
+
84
+ Override services per call:
85
+
86
+ ```javascript
87
+ const result = await acp.process('prompt', {
88
+ services: [{ cli: 'opencode' }, { cli: 'kilo' }],
89
+ });
90
+ ```
91
+
92
+ Multiple profiles per provider (useful for separate API key accounts):
93
+
94
+ ```javascript
95
+ const acp = new ACPProtocol('instruction', [
96
+ { cli: 'kilo', profile: 'account-a' },
97
+ { cli: 'kilo', profile: 'account-b' },
98
+ { cli: 'opencode' },
99
+ ]);
100
+ ```
101
+
102
+ ### createServiceStack
103
+
104
+ Build a typed service stack for use with `FallbackEngine` directly:
105
+
106
+ ```javascript
107
+ import { createServiceStack, FallbackEngine } from 'acpreact';
108
+
109
+ const stack = createServiceStack([
110
+ { cli: 'kilo', profile: 'work' },
111
+ { cli: 'opencode' },
112
+ { cli: 'gemini' },
113
+ ]);
114
+
115
+ const engine = new FallbackEngine(stack);
116
+ ```
117
+
118
+ ### Fallback Events
119
+
120
+ `ACPProtocol` (and `FallbackEngine`) emit events during fallback:
121
+
122
+ ```javascript
123
+ acp.on('rate-limited', ({ name, profileId, cooldownMs }) => {
124
+ console.log(`${name}(${profileId}) rate-limited for ${cooldownMs}ms`);
125
+ });
126
+
127
+ acp.on('fallback', ({ from, to }) => {
128
+ console.log(`Falling back from ${from.name} to ${to.name}`);
129
+ });
130
+
131
+ acp.on('success', ({ name, profileId, attempted }) => {
132
+ console.log(`Succeeded on ${name}(${profileId}) after ${attempted} attempt(s)`);
133
+ });
134
+ ```
135
+
136
+ ### Using System Instructions
137
+
138
+ ```javascript
139
+ import { ACPProtocol } from 'acpreact';
140
+
141
+ const acp = new ACPProtocol('You are a helpful weather assistant. Always provide temperature in Fahrenheit.');
65
142
 
66
- // Register tools as usual
67
143
  acp.registerTool(
68
144
  'weather',
69
145
  'Get weather information for a location',
@@ -74,85 +150,100 @@ acp.registerTool(
74
150
  },
75
151
  required: ['location']
76
152
  },
77
- async (params) => {
78
- return {
79
- location: params.location,
80
- temperature: 72,
81
- condition: 'sunny'
82
- };
83
- }
153
+ async (params) => ({
154
+ location: params.location,
155
+ temperature: 72,
156
+ condition: 'sunny'
157
+ })
84
158
  );
85
159
 
86
- // The instruction is included in the initialization response
87
- const response = acp.createInitializeResponse();
88
- console.log(response.result.instruction);
89
- // Output: "You are a helpful weather assistant. Always provide temperature in Fahrenheit."
160
+ const result = await acp.process('What is the weather in San Francisco?', { cli: 'kilo' });
161
+ console.log(result.text); // text response (tool call JSON filtered out)
162
+ console.log(result.toolCalls); // [{ tool: 'weather', result: { location: 'San Francisco', ... } }]
90
163
  ```
91
164
 
92
165
  ## API
93
166
 
94
167
  ### ACPProtocol
95
168
 
96
- Main class for setting up ACP protocol communication.
169
+ Main class for ACP protocol communication.
97
170
 
98
171
  **Constructor:**
99
172
 
100
- - `new ACPProtocol(instruction)`: Initialize the protocol
101
- - `instruction` (optional): String - system instruction to communicate to opencode or kilo CLI
173
+ - `new ACPProtocol(instruction?, services?)`: Initialize the protocol
174
+ - `instruction` (optional): String - system instruction prepended to every prompt sent to the CLI
175
+ - `services` (optional): Array of `{ cli, profile?, model? }` - instance-level default service stack for multi-service fallback
102
176
 
103
177
  **Methods:**
104
178
 
105
179
  - `registerTool(name, description, inputSchema, handler)`: Register a custom tool
106
180
  - `name`: String - tool identifier
107
- - `description`: String - tool description
181
+ - `description`: String - tool description shown to the model
108
182
  - `inputSchema`: Object - JSON Schema for tool inputs
109
183
  - `handler`: Async function - receives params object, returns result
110
184
  - Returns: Tool definition object
111
185
 
112
- - `createInitializeResponse()`: Generate protocol initialization response
186
+ - `async process(text, options?)`: Send a prompt to a CLI and execute any tool calls
187
+ - `text`: String - the user prompt
188
+ - `options.cli`: `'kilo'` (default), `'opencode'`, or `'gemini'` — used when `options.services` not set
189
+ - `options.services`: Array of `{ cli, profile?, model? }` — enables multi-service fallback for this call; takes precedence over `options.cli`
190
+ - `options.model`: String - model in `provider/model` format (uses CLI default if omitted)
191
+ - `options.timeout`: Number - per-attempt timeout in ms (default 120000)
192
+ - Returns: `{ text, rawOutput, toolCalls, logs }` or `{ text, rawOutput, error, logs }` on parse failure
193
+ - Throws `AggregateError` when all services in the stack are exhausted
194
+
195
+ - `createInitializeResponse()`: Generate ACP protocol initialization response with registered tools
196
+
197
+ - `createJsonRpcRequest(method, params)`: Create JSON-RPC 2.0 request object
113
198
 
114
- - `createJsonRpcRequest(method, params)`: Create JSON-RPC request object
199
+ - `createJsonRpcResponse(id, result)`: Create JSON-RPC 2.0 response object
115
200
 
116
- - `createJsonRpcResponse(id, result)`: Create JSON-RPC response object
201
+ - `createJsonRpcError(id, error)`: Create JSON-RPC 2.0 error object (accepts Error or string)
117
202
 
118
- - `createJsonRpcError(id, error)`: Create JSON-RPC error object
203
+ - `validateToolCall(toolName)`: Check if tool is whitelisted, returns `{ allowed, error? }`
119
204
 
120
- - `validateToolCall(toolName)`: Check if tool is whitelisted
205
+ - `async callTool(toolName, params)`: Execute a registered tool directly
121
206
 
122
- - `async callTool(toolName, params)`: Execute a registered tool
207
+ - `parseTextOutput(output)`: Parse human-readable text from CLI JSON output (filters tool call JSON)
208
+
209
+ - `parseToolCalls(output)`: Parse JSON-RPC tool calls from CLI output, deduplicated by id+method
123
210
 
124
211
  **Properties:**
125
212
 
126
- - `instruction`: String (optional) - system instruction communicated to opencode or kilo CLI
213
+ - `instruction`: String (optional) - system instruction prepended to prompts
127
214
  - `toolWhitelist`: Set of registered tool names
128
- - `toolCallLog`: Array of executed tool calls with timestamps
129
- - `rejectedCallLog`: Array of rejected tool attempts
215
+ - `toolCallLog`: Array of executed tool calls with timestamps and results
216
+ - `rejectedCallLog`: Array of rejected tool attempts with reasons
217
+
218
+ ## How It Works
219
+
220
+ `process()` injects the registered tool list and JSON-RPC call format into the prompt, invokes the CLI, and parses any JSON-RPC tool calls from the output. Matched tool calls are executed locally and their results returned.
221
+
222
+ The model outputs tool calls as JSON-RPC lines:
223
+ ```
224
+ {"jsonrpc":"2.0","id":1,"method":"tools/add","params":{"a":15,"b":27}}
225
+ ```
226
+
227
+ These are parsed, executed, and returned in `result.toolCalls`. The `text` field contains only human-readable model output with tool call JSON filtered out.
130
228
 
131
229
  ## Example: Multiple Tools
132
230
 
133
231
  ```javascript
134
232
  import { ACPProtocol } from 'acpreact';
135
233
 
136
- const acp = new ACPProtocol();
234
+ const acp = new ACPProtocol('You are a data assistant.');
137
235
 
138
- // Register database tool
139
236
  acp.registerTool(
140
237
  'query_database',
141
238
  'Query the application database',
142
239
  {
143
240
  type: 'object',
144
- properties: {
145
- query: { type: 'string' }
146
- },
241
+ properties: { query: { type: 'string' } },
147
242
  required: ['query']
148
243
  },
149
- async (params) => {
150
- // Your database logic here
151
- return { data: [] };
152
- }
244
+ async (params) => ({ data: [] })
153
245
  );
154
246
 
155
- // Register API tool
156
247
  acp.registerTool(
157
248
  'call_api',
158
249
  'Call an external API',
@@ -164,15 +255,12 @@ acp.registerTool(
164
255
  },
165
256
  required: ['endpoint', 'method']
166
257
  },
167
- async (params) => {
168
- // Your API logic here
169
- return { response: {} };
170
- }
258
+ async (params) => ({ response: {} })
171
259
  );
172
260
 
173
- // Initialize and use
174
261
  const initResponse = acp.createInitializeResponse();
175
- console.log(initResponse.result.agentCapabilities);
262
+ console.log(initResponse.result.tools.length); // 2
263
+ console.log(initResponse.result.agentCapabilities); // { toolCalling: true, streaming: false }
176
264
  ```
177
265
 
178
266
  ## License
package/core.js CHANGED
@@ -1,8 +1,44 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { EventEmitter } from 'events';
3
+ import { parseTextOutput, parseToolCalls } from './parser.js';
4
+ import { ServiceRegistry, createServiceStack, buildArgs } from './services.js';
5
+ import { FallbackEngine } from './fallback.js';
6
+
7
+ const DEFAULT_TIMEOUT_MS = 120_000;
8
+
9
+ function attachOutputs(err, output, errorOutput) {
10
+ err.output = output;
11
+ err.stderr = errorOutput;
12
+ return err;
13
+ }
14
+
15
+ function spawnService(entry, prompt, options, callbacks) {
16
+ return new Promise((resolve, reject) => {
17
+ const binary = entry.config?.binary || entry.name;
18
+ const args = entry.config?.buildArgs
19
+ ? entry.config.buildArgs(prompt, options)
20
+ : buildArgs(entry.name, prompt, options);
21
+ let output = '', errorOutput = '';
22
+ const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd(), env: { ...process.env } });
23
+ child.stdin.end();
24
+ child.stdout.on('data', (d) => { const c = d.toString(); output += c; callbacks?.onOutput?.(c); });
25
+ child.stderr.on('data', (d) => { const c = d.toString(); errorOutput += c; callbacks?.onStderr?.(c); });
26
+ const timer = setTimeout(() => {
27
+ child.kill();
28
+ reject(attachOutputs(new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`), output, errorOutput));
29
+ }, options?.timeout ?? DEFAULT_TIMEOUT_MS);
30
+ child.on('close', (code) => {
31
+ clearTimeout(timer);
32
+ if (code !== 0 && code !== null && !output)
33
+ return reject(attachOutputs(new Error(`${binary} exited with code ${code}: ${errorOutput}`), output, errorOutput));
34
+ resolve({ rawOutput: output, stderr: errorOutput, code });
35
+ });
36
+ child.on('error', (err) => { clearTimeout(timer); reject(attachOutputs(err, output, errorOutput)); });
37
+ });
38
+ }
3
39
 
4
40
  class ACPProtocol extends EventEmitter {
5
- constructor(instruction) {
41
+ constructor(instruction, services) {
6
42
  super();
7
43
  this.messageId = 0;
8
44
  this.instruction = instruction;
@@ -12,11 +48,19 @@ class ACPProtocol extends EventEmitter {
12
48
  this.toolCallLog = [];
13
49
  this.rejectedCallLog = [];
14
50
  this.tools = {};
51
+ this.registry = new ServiceRegistry();
52
+ if (services) {
53
+ for (const svc of services) {
54
+ this.registry.registerService(svc.cli || svc.name, svc);
55
+ }
56
+ }
57
+ this.fallback = new FallbackEngine([]);
58
+ this.fallback.on('rate-limited', (e) => this.emit('rate-limited', e));
59
+ this.fallback.on('fallback', (e) => this.emit('fallback', e));
60
+ this.fallback.on('success', (e) => this.emit('success', e));
15
61
  }
16
62
 
17
- generateRequestId() {
18
- return ++this.messageId;
19
- }
63
+ generateRequestId() { return ++this.messageId; }
20
64
 
21
65
  registerTool(name, description, inputSchema, handler) {
22
66
  this.toolWhitelist.add(name);
@@ -27,17 +71,16 @@ class ACPProtocol extends EventEmitter {
27
71
  }
28
72
 
29
73
  getToolsList() {
30
- return Array.from(this.toolWhitelist).map(toolName => ({
31
- name: toolName,
32
- description: this.toolDescriptions[toolName],
33
- inputSchema: this.toolSchemas[toolName],
74
+ return Array.from(this.toolWhitelist).map(name => ({
75
+ name,
76
+ description: this.toolDescriptions[name],
77
+ inputSchema: this.toolSchemas[name],
34
78
  }));
35
79
  }
36
80
 
37
81
  getToolsPrompt() {
38
82
  const tools = this.getToolsList();
39
83
  if (tools.length === 0) return '';
40
-
41
84
  let prompt = '\n\nYou have access to the following tools. You MUST use these tools to interact:\n\n';
42
85
  for (const tool of tools) {
43
86
  prompt += `## Tool: ${tool.name}\n${tool.description}\n`;
@@ -49,178 +92,88 @@ class ACPProtocol extends EventEmitter {
49
92
  return prompt;
50
93
  }
51
94
 
95
+ createInitializeResponse() {
96
+ return {
97
+ jsonrpc: '2.0', id: 1,
98
+ result: {
99
+ tools: this.getToolsList(),
100
+ instruction: this.instruction,
101
+ agentCapabilities: { toolCalling: true, streaming: false },
102
+ },
103
+ };
104
+ }
105
+
106
+ createJsonRpcRequest(method, params) {
107
+ return { jsonrpc: '2.0', id: this.generateRequestId(), method, params };
108
+ }
109
+
110
+ createJsonRpcResponse(id, result) { return { jsonrpc: '2.0', id, result }; }
111
+
112
+ createJsonRpcError(id, error) {
113
+ return {
114
+ jsonrpc: '2.0', id,
115
+ error: { code: error?.code ?? -32000, message: error instanceof Error ? error.message : String(error) },
116
+ };
117
+ }
118
+
52
119
  validateToolCall(toolName) {
53
120
  if (!this.toolWhitelist.has(toolName)) {
54
121
  const availableTools = Array.from(this.toolWhitelist);
55
- const error = `Tool not available. Available: ${availableTools.join(', ')}`;
56
- this.rejectedCallLog.push({
57
- timestamp: new Date().toISOString(),
58
- attemptedTool: toolName,
59
- reason: 'Not in whitelist',
60
- availableTools,
61
- });
62
- return { allowed: false, error };
122
+ this.rejectedCallLog.push({ timestamp: new Date().toISOString(), attemptedTool: toolName, reason: 'Not in whitelist', availableTools });
123
+ return { allowed: false, error: `Tool not available. Available: ${availableTools.join(', ')}` };
63
124
  }
64
125
  return { allowed: true };
65
126
  }
66
127
 
67
128
  async callTool(toolName, params) {
68
129
  const validation = this.validateToolCall(toolName);
69
- if (!validation.allowed) {
70
- throw new Error(validation.error);
71
- }
72
-
73
- this.toolCallLog.push({
74
- timestamp: new Date().toISOString(),
75
- toolName,
76
- params,
77
- status: 'executing',
78
- });
79
-
80
- if (this.tools[toolName]) {
81
- const result = await this.tools[toolName](params);
82
- const lastLog = this.toolCallLog[this.toolCallLog.length - 1];
83
- lastLog.status = 'completed';
84
- lastLog.result = result;
85
- return result;
86
- }
87
- throw new Error(`Unknown tool: ${toolName}`);
130
+ if (!validation.allowed) throw new Error(validation.error);
131
+ const entry = { timestamp: new Date().toISOString(), toolName, params, status: 'executing' };
132
+ this.toolCallLog.push(entry);
133
+ if (!this.tools[toolName]) throw new Error(`Unknown tool: ${toolName}`);
134
+ const result = await this.tools[toolName](params);
135
+ entry.status = 'completed';
136
+ entry.result = result;
137
+ return result;
88
138
  }
89
139
 
90
- parseTextOutput(output) {
91
- let text = '';
92
- const lines = output.split('\n');
93
-
94
- for (const line of lines) {
95
- const trimmed = line.trim();
96
- if (!trimmed) continue;
97
-
98
- try {
99
- const json = JSON.parse(trimmed);
100
- if (json.type === 'text' && json.part?.text) {
101
- text += json.part.text;
102
- }
103
- } catch {}
104
- }
105
-
106
- return text;
107
- }
108
-
109
- parseToolCalls(output) {
110
- const calls = [];
111
-
112
- const textContent = this.parseTextOutput(output);
113
-
114
- for (const line of textContent.split('\n')) {
115
- const trimmed = line.trim();
116
- if (!trimmed) continue;
117
-
118
- try {
119
- const json = JSON.parse(trimmed);
120
- if (json.jsonrpc === '2.0' && json.method?.startsWith('tools/') && json.params) {
121
- calls.push({
122
- id: json.id,
123
- method: json.method,
124
- params: json.params
125
- });
126
- }
127
- } catch {}
128
- }
129
-
130
- const lines = output.split('\n');
131
- for (const line of lines) {
132
- const trimmed = line.trim();
133
- if (!trimmed) continue;
134
-
135
- try {
136
- const json = JSON.parse(trimmed);
137
- if (json.jsonrpc === '2.0' && json.method?.startsWith('tools/') && json.params) {
138
- calls.push({
139
- id: json.id,
140
- method: json.method,
141
- params: json.params
142
- });
143
- }
144
- } catch {}
145
- }
146
-
147
- return calls;
148
- }
140
+ parseTextOutput(output) { return parseTextOutput(output); }
141
+ parseToolCalls(output) { return parseToolCalls(output); }
149
142
 
150
143
  async process(text, options = {}) {
151
- const cli = options.cli || 'kilo';
152
- const model = options.model || 'kilo/z-ai/glm-5:free';
153
-
154
- const fullPrompt = this.instruction
144
+ const fullPrompt = this.instruction
155
145
  ? `${this.instruction}${this.getToolsPrompt()}\n\n---\n\n${text}`
156
146
  : `${this.getToolsPrompt()}\n\n---\n\n${text}`;
157
147
 
158
- const escapedPrompt = fullPrompt.replace(/"/g, '\\"').replace(/\n/g, ' ');
159
-
160
- return new Promise((resolve, reject) => {
161
- let output = '';
162
- let errorOutput = '';
163
-
164
- const child = spawn('script', ['-q', '-c',
165
- `${cli} run --auto --model ${model} --format json "${escapedPrompt}"`,
166
- '/dev/null'
167
- ], {
168
- stdio: ['pipe', 'pipe', 'pipe'],
169
- cwd: process.cwd(),
170
- env: { ...process.env, TERM: 'dumb' },
171
- });
172
-
173
- child.stdout.on('data', (data) => {
174
- output += data.toString();
175
- });
176
-
177
- child.stderr.on('data', (data) => {
178
- errorOutput += data.toString();
179
- });
180
-
181
- child.on('close', async (code) => {
182
- if (code !== 0 && code !== null && !output) {
183
- reject(new Error(`CLI exited with code ${code}: ${errorOutput}`));
184
- return;
185
- }
148
+ let stack;
149
+ if (options.services) {
150
+ stack = createServiceStack(options.services);
151
+ } else if (options.cli) {
152
+ stack = [{ name: options.cli, profileId: '__default__', config: {} }];
153
+ } else {
154
+ stack = this.registry.getAll().length > 0
155
+ ? this.registry.getAvailable()
156
+ : [{ name: 'kilo', profileId: '__default__', config: {} }];
157
+ }
186
158
 
187
- try {
188
- const toolCalls = this.parseToolCalls(output);
189
- const results = [];
190
-
191
- for (const call of toolCalls) {
192
- const toolName = call.method.replace('tools/', '');
193
- if (this.toolWhitelist.has(toolName)) {
194
- const result = await this.callTool(toolName, call.params);
195
- results.push({ tool: toolName, result });
196
- }
197
- }
198
-
199
- resolve({
200
- text: this.parseTextOutput(output),
201
- rawOutput: output,
202
- toolCalls: results,
203
- logs: this.toolCallLog
204
- });
205
- } catch (e) {
206
- resolve({
207
- text: this.parseTextOutput(output),
208
- rawOutput: output,
209
- error: e.message,
210
- logs: this.toolCallLog
211
- });
212
- }
213
- });
159
+ this.fallback._stack = stack;
214
160
 
215
- child.on('error', (error) => {
216
- reject(new Error(`Failed to spawn ${cli}: ${error.message}`));
217
- });
161
+ const { rawOutput } = await this.fallback.run(spawnService, fullPrompt, options);
218
162
 
219
- setTimeout(() => {
220
- child.kill();
221
- reject(new Error('Timeout'));
222
- }, 120000);
223
- });
163
+ try {
164
+ const toolCalls = parseToolCalls(rawOutput);
165
+ const results = [];
166
+ for (const call of toolCalls) {
167
+ const toolName = call.method.replace('tools/', '');
168
+ if (this.toolWhitelist.has(toolName)) {
169
+ const result = await this.callTool(toolName, call.params);
170
+ results.push({ tool: toolName, result });
171
+ }
172
+ }
173
+ return { text: parseTextOutput(rawOutput), rawOutput, toolCalls: results, logs: this.toolCallLog };
174
+ } catch (e) {
175
+ return { text: parseTextOutput(rawOutput), rawOutput, error: e.message, logs: this.toolCallLog };
176
+ }
224
177
  }
225
178
 
226
179
  stop() {}
package/fallback.js ADDED
@@ -0,0 +1,66 @@
1
+ import { EventEmitter } from 'events';
2
+ import { isRateLimited, DEFAULT_COOLDOWN_MS } from './services.js';
3
+
4
+ class FallbackEngine extends EventEmitter {
5
+ constructor(serviceStack = []) {
6
+ super();
7
+ this._stack = serviceStack;
8
+ }
9
+
10
+ async run(spawnFn, text, options = {}) {
11
+ if (this._stack.length === 0) {
12
+ throw new Error('FallbackEngine: service stack is empty');
13
+ }
14
+
15
+ const errors = [];
16
+ let attempted = 0;
17
+
18
+ for (const entry of this._stack) {
19
+ const { name, profileId } = entry;
20
+ attempted++;
21
+
22
+ let result;
23
+ let spawnError;
24
+ let output = '';
25
+ let stderr = '';
26
+
27
+ try {
28
+ result = await spawnFn(entry, text, options, {
29
+ onOutput: (chunk) => { output += chunk; },
30
+ onStderr: (chunk) => { stderr += chunk; },
31
+ });
32
+ } catch (err) {
33
+ spawnError = err;
34
+ output = err.output || '';
35
+ stderr = err.stderr || '';
36
+ }
37
+
38
+ const rlCheck = isRateLimited(name, output, stderr);
39
+ const isMissing = spawnError?.code === 'ENOENT';
40
+
41
+ if (!spawnError && !rlCheck.rateLimited) {
42
+ this.emit('success', { name, profileId, attempted });
43
+ return result;
44
+ }
45
+
46
+ if (rlCheck.rateLimited || isMissing) {
47
+ const cooldownMs = rlCheck.retryAfterMs ?? DEFAULT_COOLDOWN_MS;
48
+ this.emit('rate-limited', { name, profileId, cooldownMs, error: spawnError?.message });
49
+ errors.push({ name, profileId, rateLimited: true, retryAfterMs: cooldownMs, error: spawnError?.message });
50
+ const remaining = this._stack.slice(attempted);
51
+ if (remaining.length > 0) this.emit('fallback', { from: { name, profileId }, to: remaining[0] });
52
+ continue;
53
+ }
54
+
55
+ throw spawnError || new Error(`Service ${name} failed: ${output}${stderr}`);
56
+ }
57
+
58
+ const summary = errors.map(e => `${e.name}(${e.profileId}): rate-limited`).join(', ');
59
+ throw new AggregateError(
60
+ errors.map(e => Object.assign(new Error(`${e.name} rate-limited`), e)),
61
+ `All services exhausted: ${summary}`
62
+ );
63
+ }
64
+ }
65
+
66
+ export { FallbackEngine };
package/index.js CHANGED
@@ -1,28 +1,49 @@
1
1
  import { ACPProtocol } from './core.js';
2
+ import { ServiceRegistry, isRateLimited, createServiceStack } from './services.js';
3
+ import { FallbackEngine } from './fallback.js';
2
4
 
3
- export { ACPProtocol };
5
+ export { ACPProtocol, ServiceRegistry, FallbackEngine, isRateLimited, createServiceStack };
4
6
 
5
7
  /*
6
- * acpreact - ACP SDK for registering tools
8
+ * acpreact - ACP SDK for registering tools with multi-service fallback
7
9
  *
8
- * Basic usage:
10
+ * Basic usage (single service, backward compatible):
9
11
  * import { ACPProtocol } from 'acpreact';
12
+ * const acp = new ACPProtocol('Your instruction');
13
+ * const result = await acp.process('prompt', { cli: 'kilo' });
10
14
  *
11
- * Create ACP Protocol instance:
12
- * const acp = new ACPProtocol();
15
+ * Multi-service fallback pass a services array to the constructor:
16
+ * const acp = new ACPProtocol('Your instruction', [
17
+ * { cli: 'kilo' },
18
+ * { cli: 'opencode' },
19
+ * { cli: 'gemini' },
20
+ * ]);
21
+ * const result = await acp.process('prompt');
13
22
  *
14
- * Register custom tools:
15
- * acp.registerTool('my_tool', 'Tool description', {
16
- * type: 'object',
17
- * properties: { query: { type: 'string' } },
18
- * required: ['query']
19
- * }, async (params) => {
20
- * return { result: 'processed' };
23
+ * Per-call override:
24
+ * const result = await acp.process('prompt', {
25
+ * services: [{ cli: 'opencode' }, { cli: 'kilo' }],
21
26
  * });
22
27
  *
23
- * Initialize protocol:
24
- * const response = acp.createInitializeResponse();
28
+ * Custom ACP-compatible provider:
29
+ * acp.registry.registerService('my-agent', {
30
+ * binary: 'my-agent',
31
+ * buildArgs: (prompt, opts) => ['run', prompt],
32
+ * });
33
+ *
34
+ * Multiple profiles per provider (different logins):
35
+ * const acp = new ACPProtocol('instruction', [
36
+ * { cli: 'kilo', profile: 'account-a' },
37
+ * { cli: 'kilo', profile: 'account-b' },
38
+ * { cli: 'opencode', profile: 'account-c' },
39
+ * ]);
40
+ *
41
+ * Listen to fallback events:
42
+ * acp.on('rate-limited', ({ name, profileId, cooldownMs }) => { ... });
43
+ * acp.on('fallback', ({ from, to }) => { ... });
44
+ * acp.on('success', ({ name, profileId, attempted }) => { ... });
25
45
  *
26
- * Execute tool:
27
- * const result = await acp.callTool('my_tool', { query: 'test' });
46
+ * Build a service stack manually:
47
+ * import { createServiceStack, ServiceRegistry, FallbackEngine } from 'acpreact';
48
+ * const stack = createServiceStack([{ cli: 'kilo' }, { cli: 'gemini' }]);
28
49
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acpreact",
3
- "version": "1.0.7",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "ACP SDK for monitoring and reacting to chat conversations",
package/parser.js ADDED
@@ -0,0 +1,58 @@
1
+ function parseTextOutput(output) {
2
+ let text = '';
3
+ for (const line of output.split('\n')) {
4
+ const trimmed = line.trim();
5
+ if (!trimmed) continue;
6
+ try {
7
+ const json = JSON.parse(trimmed);
8
+ if (json.type === 'text' && json.part?.text) {
9
+ const partText = json.part.text;
10
+ try {
11
+ const inner = JSON.parse(partText.trim());
12
+ if (inner.jsonrpc === '2.0' && inner.method?.startsWith('tools/')) continue;
13
+ } catch {}
14
+ text += partText;
15
+ }
16
+ } catch {}
17
+ }
18
+ return text;
19
+ }
20
+
21
+ function parseToolCalls(output) {
22
+ const seen = new Set();
23
+ const calls = [];
24
+
25
+ const tryAdd = (candidate) => {
26
+ const trimmed = candidate.trim();
27
+ if (!trimmed) return;
28
+ try {
29
+ const json = JSON.parse(trimmed);
30
+ if (json.jsonrpc === '2.0' && json.method?.startsWith('tools/') && json.params) {
31
+ const key = `${json.id}:${json.method}`;
32
+ if (!seen.has(key)) {
33
+ seen.add(key);
34
+ calls.push({ id: json.id, method: json.method, params: json.params });
35
+ }
36
+ }
37
+ } catch {}
38
+ };
39
+
40
+ for (const line of output.split('\n')) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed) continue;
43
+ try {
44
+ const json = JSON.parse(trimmed);
45
+ if (json.type === 'text' && json.part?.text) {
46
+ for (const inner of json.part.text.split('\n')) tryAdd(inner);
47
+ } else {
48
+ tryAdd(trimmed);
49
+ }
50
+ } catch {
51
+ tryAdd(trimmed);
52
+ }
53
+ }
54
+
55
+ return calls;
56
+ }
57
+
58
+ export { parseTextOutput, parseToolCalls };
package/services.js ADDED
@@ -0,0 +1,139 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ const DEFAULT_COOLDOWN_MS = 60_000;
4
+
5
+ const RATE_LIMIT_PATTERNS = {
6
+ common: [
7
+ /\b429\b/,
8
+ /rate.?limit/i,
9
+ /quota.?exceeded/i,
10
+ /too.?many.?requests/i,
11
+ ],
12
+ gemini: [/RESOURCE_EXHAUSTED/],
13
+ kilo: [],
14
+ opencode: [],
15
+ };
16
+
17
+ const RETRY_AFTER_PATTERN = /retry.?after[:\s]+(\d+)/i;
18
+
19
+ const BUILTIN_ARG_BUILDERS = {
20
+ kilo: (prompt, options) => {
21
+ const args = ['run', '--format', 'json', '--auto'];
22
+ if (options?.model) args.push('--model', options.model);
23
+ args.push(prompt);
24
+ return args;
25
+ },
26
+ opencode: (prompt, options) => {
27
+ const args = ['run', '--format', 'json'];
28
+ if (options?.model) args.push('--model', options.model);
29
+ args.push(prompt);
30
+ return args;
31
+ },
32
+ gemini: (prompt, options) => {
33
+ const args = ['run'];
34
+ if (options?.model) args.push('--model', options.model);
35
+ args.push(prompt);
36
+ return args;
37
+ },
38
+ };
39
+
40
+ function buildArgs(providerName, prompt, options) {
41
+ const builder = BUILTIN_ARG_BUILDERS[providerName];
42
+ if (builder) return builder(prompt, options);
43
+ const args = ['run'];
44
+ if (options?.model) args.push('--model', options.model);
45
+ args.push(prompt);
46
+ return args;
47
+ }
48
+
49
+ function isRateLimited(providerName, output = '', stderr = '') {
50
+ const combined = `${output}\n${stderr}`;
51
+ const patterns = [
52
+ ...RATE_LIMIT_PATTERNS.common,
53
+ ...(RATE_LIMIT_PATTERNS[providerName] || []),
54
+ ];
55
+
56
+ let rateLimited = false;
57
+ let retryAfterMs;
58
+
59
+ for (const pattern of patterns) {
60
+ if (pattern.test(combined)) {
61
+ rateLimited = true;
62
+ break;
63
+ }
64
+ }
65
+
66
+ if (rateLimited) {
67
+ const match = combined.match(RETRY_AFTER_PATTERN);
68
+ if (match) retryAfterMs = parseInt(match[1], 10) * 1000;
69
+ }
70
+
71
+ return retryAfterMs !== undefined ? { rateLimited, retryAfterMs } : { rateLimited };
72
+ }
73
+
74
+ function createServiceStack(configs) {
75
+ return configs.map(cfg => ({
76
+ name: cfg.cli || cfg.name,
77
+ profileId: cfg.profile ?? '__default__',
78
+ config: cfg,
79
+ }));
80
+ }
81
+
82
+ class ServiceRegistry extends EventEmitter {
83
+ constructor() {
84
+ super();
85
+ this._services = [];
86
+ this._cooldowns = new Map();
87
+ }
88
+
89
+ _cooldownKey(name, profileId) {
90
+ return `${name}::${profileId ?? '__default__'}`;
91
+ }
92
+
93
+ registerService(name, config = {}) {
94
+ const profileId = config.profile ?? '__default__';
95
+ const existing = this._services.findIndex(
96
+ s => s.name === name && s.profileId === profileId
97
+ );
98
+ const entry = { name, profileId, config };
99
+ if (existing >= 0) {
100
+ this._services[existing] = entry;
101
+ } else {
102
+ this._services.push(entry);
103
+ }
104
+ this._cooldowns.delete(this._cooldownKey(name, profileId));
105
+ return this;
106
+ }
107
+
108
+ markRateLimited(name, profileId, cooldownMs = DEFAULT_COOLDOWN_MS) {
109
+ const key = this._cooldownKey(name, profileId ?? '__default__');
110
+ const expiry = cooldownMs === 0 ? 0 : Date.now() + cooldownMs;
111
+ this._cooldowns.set(key, expiry);
112
+ this.emit('rate-limited', { name, profileId, cooldownMs, expiry });
113
+ }
114
+
115
+ clearCooldown(name, profileId) {
116
+ this._cooldowns.delete(this._cooldownKey(name, profileId ?? '__default__'));
117
+ }
118
+
119
+ isAvailable(name, profileId) {
120
+ const key = this._cooldownKey(name, profileId ?? '__default__');
121
+ if (!this._cooldowns.has(key)) return true;
122
+ const expiry = this._cooldowns.get(key);
123
+ if (expiry === 0 || Date.now() >= expiry) {
124
+ this._cooldowns.delete(key);
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ getAvailable() {
131
+ return this._services.filter(s => this.isAvailable(s.name, s.profileId));
132
+ }
133
+
134
+ getAll() {
135
+ return [...this._services];
136
+ }
137
+ }
138
+
139
+ export { ServiceRegistry, isRateLimited, createServiceStack, buildArgs, DEFAULT_COOLDOWN_MS };