acpreact 1.0.8 → 1.1.2
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 +81 -5
- package/core.js +94 -82
- package/fallback.js +66 -0
- package/index.js +37 -16
- package/package.json +1 -1
- package/services.js +139 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# acpreact - ACP SDK
|
|
2
2
|
|
|
3
|
-
A lightweight SDK for registering tools and running them via kilo CLI or
|
|
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
|
|
|
@@ -8,7 +8,9 @@ A lightweight SDK for registering tools and running them via kilo CLI or opencod
|
|
|
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
10
|
- **Tool Execution**: Execute whitelisted tools with validation and logging
|
|
11
|
-
- **CLI Integration**: Works with kilo CLI and
|
|
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
|
|
12
14
|
- **ES Module**: Pure ES modules, no build step required
|
|
13
15
|
|
|
14
16
|
## Prerequisites
|
|
@@ -61,6 +63,76 @@ console.log(result.logs); // tool call audit log
|
|
|
61
63
|
const result = await acp.process('What is 15 + 27? Use the add tool.', { cli: 'opencode' });
|
|
62
64
|
```
|
|
63
65
|
|
|
66
|
+
### Multi-Service Fallback
|
|
67
|
+
|
|
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:
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
import { ACPProtocol } from 'acpreact';
|
|
72
|
+
|
|
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
|
+
|
|
64
136
|
### Using System Instructions
|
|
65
137
|
|
|
66
138
|
```javascript
|
|
@@ -98,8 +170,9 @@ Main class for ACP protocol communication.
|
|
|
98
170
|
|
|
99
171
|
**Constructor:**
|
|
100
172
|
|
|
101
|
-
- `new ACPProtocol(instruction?)`: Initialize the protocol
|
|
173
|
+
- `new ACPProtocol(instruction?, services?)`: Initialize the protocol
|
|
102
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
|
|
103
176
|
|
|
104
177
|
**Methods:**
|
|
105
178
|
|
|
@@ -110,11 +183,14 @@ Main class for ACP protocol communication.
|
|
|
110
183
|
- `handler`: Async function - receives params object, returns result
|
|
111
184
|
- Returns: Tool definition object
|
|
112
185
|
|
|
113
|
-
- `async process(text, options?)`: Send a prompt to
|
|
186
|
+
- `async process(text, options?)`: Send a prompt to a CLI and execute any tool calls
|
|
114
187
|
- `text`: String - the user prompt
|
|
115
|
-
- `options.cli`: `'kilo'` (default) or `'
|
|
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`
|
|
116
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)
|
|
117
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
|
|
118
194
|
|
|
119
195
|
- `createInitializeResponse()`: Generate ACP protocol initialization response with registered tools
|
|
120
196
|
|
package/core.js
CHANGED
|
@@ -1,9 +1,46 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
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) { err.output = output; err.stderr = errorOutput; return err; }
|
|
10
|
+
|
|
11
|
+
function spawnService(entry, prompt, options, callbacks) {
|
|
12
|
+
const abortSignal = options?._abortSignal;
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
if (abortSignal?.aborted) return reject(new Error('Aborted'));
|
|
15
|
+
const binary = entry.config?.binary || entry.name;
|
|
16
|
+
const args = entry.config?.buildArgs
|
|
17
|
+
? entry.config.buildArgs(prompt, options)
|
|
18
|
+
: buildArgs(entry.name, prompt, options);
|
|
19
|
+
let output = '', errorOutput = '';
|
|
20
|
+
const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd(), env: { ...process.env } });
|
|
21
|
+
child.stdin.end();
|
|
22
|
+
child.stdout.on('data', (d) => { const c = d.toString(); output += c; callbacks?.onOutput?.(c); });
|
|
23
|
+
child.stderr.on('data', (d) => { const c = d.toString(); errorOutput += c; callbacks?.onStderr?.(c); });
|
|
24
|
+
const timeoutMs = options?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
child.kill();
|
|
27
|
+
reject(attachOutputs(new Error(`Timeout after ${timeoutMs}ms`), output, errorOutput));
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
const onAbort = () => { child.kill(); clearTimeout(timer); reject(attachOutputs(new Error('Aborted'), output, errorOutput)); };
|
|
30
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
31
|
+
child.on('close', (code) => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
34
|
+
if (code !== 0 && code !== null && !output)
|
|
35
|
+
return reject(attachOutputs(new Error(`${binary} exited with code ${code}: ${errorOutput}`), output, errorOutput));
|
|
36
|
+
resolve({ rawOutput: output, stderr: errorOutput, code });
|
|
37
|
+
});
|
|
38
|
+
child.on('error', (err) => { clearTimeout(timer); abortSignal?.removeEventListener('abort', onAbort); reject(attachOutputs(err, output, errorOutput)); });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
4
41
|
|
|
5
42
|
class ACPProtocol extends EventEmitter {
|
|
6
|
-
constructor(instruction) {
|
|
43
|
+
constructor(instruction, services) {
|
|
7
44
|
super();
|
|
8
45
|
this.messageId = 0;
|
|
9
46
|
this.instruction = instruction;
|
|
@@ -13,11 +50,23 @@ class ACPProtocol extends EventEmitter {
|
|
|
13
50
|
this.toolCallLog = [];
|
|
14
51
|
this.rejectedCallLog = [];
|
|
15
52
|
this.tools = {};
|
|
53
|
+
this.registry = new ServiceRegistry();
|
|
54
|
+
if (services) {
|
|
55
|
+
for (const svc of services) {
|
|
56
|
+
this.registry.registerService(svc.cli || svc.name, svc);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this.fallback = new FallbackEngine([]);
|
|
60
|
+
this.fallback.on('rate-limited', (e) => {
|
|
61
|
+
this.registry.markRateLimited(e.name, e.profileId, e.cooldownMs);
|
|
62
|
+
this.emit('rate-limited', e);
|
|
63
|
+
});
|
|
64
|
+
this.fallback.on('fallback', (e) => this.emit('fallback', e));
|
|
65
|
+
this.fallback.on('success', (e) => this.emit('success', e));
|
|
66
|
+
this._abortController = null;
|
|
16
67
|
}
|
|
17
68
|
|
|
18
|
-
generateRequestId() {
|
|
19
|
-
return ++this.messageId;
|
|
20
|
-
}
|
|
69
|
+
generateRequestId() { return ++this.messageId; }
|
|
21
70
|
|
|
22
71
|
registerTool(name, description, inputSchema, handler) {
|
|
23
72
|
this.toolWhitelist.add(name);
|
|
@@ -51,8 +100,7 @@ class ACPProtocol extends EventEmitter {
|
|
|
51
100
|
|
|
52
101
|
createInitializeResponse() {
|
|
53
102
|
return {
|
|
54
|
-
jsonrpc: '2.0',
|
|
55
|
-
id: 1,
|
|
103
|
+
jsonrpc: '2.0', id: 1,
|
|
56
104
|
result: {
|
|
57
105
|
tools: this.getToolsList(),
|
|
58
106
|
instruction: this.instruction,
|
|
@@ -65,30 +113,14 @@ class ACPProtocol extends EventEmitter {
|
|
|
65
113
|
return { jsonrpc: '2.0', id: this.generateRequestId(), method, params };
|
|
66
114
|
}
|
|
67
115
|
|
|
68
|
-
createJsonRpcResponse(id, result) {
|
|
69
|
-
return { jsonrpc: '2.0', id, result };
|
|
70
|
-
}
|
|
116
|
+
createJsonRpcResponse(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
71
117
|
|
|
72
|
-
createJsonRpcError(id, error) {
|
|
73
|
-
return {
|
|
74
|
-
jsonrpc: '2.0',
|
|
75
|
-
id,
|
|
76
|
-
error: {
|
|
77
|
-
code: error?.code ?? -32000,
|
|
78
|
-
message: error instanceof Error ? error.message : String(error),
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
}
|
|
118
|
+
createJsonRpcError(id, error) { return { jsonrpc: '2.0', id, error: { code: error?.code ?? -32000, message: error instanceof Error ? error.message : String(error) } }; }
|
|
82
119
|
|
|
83
120
|
validateToolCall(toolName) {
|
|
84
121
|
if (!this.toolWhitelist.has(toolName)) {
|
|
85
122
|
const availableTools = Array.from(this.toolWhitelist);
|
|
86
|
-
this.rejectedCallLog.push({
|
|
87
|
-
timestamp: new Date().toISOString(),
|
|
88
|
-
attemptedTool: toolName,
|
|
89
|
-
reason: 'Not in whitelist',
|
|
90
|
-
availableTools,
|
|
91
|
-
});
|
|
123
|
+
this.rejectedCallLog.push({ timestamp: new Date().toISOString(), attemptedTool: toolName, reason: 'Not in whitelist', availableTools });
|
|
92
124
|
return { allowed: false, error: `Tool not available. Available: ${availableTools.join(', ')}` };
|
|
93
125
|
}
|
|
94
126
|
return { allowed: true };
|
|
@@ -97,10 +129,8 @@ class ACPProtocol extends EventEmitter {
|
|
|
97
129
|
async callTool(toolName, params) {
|
|
98
130
|
const validation = this.validateToolCall(toolName);
|
|
99
131
|
if (!validation.allowed) throw new Error(validation.error);
|
|
100
|
-
|
|
101
132
|
const entry = { timestamp: new Date().toISOString(), toolName, params, status: 'executing' };
|
|
102
133
|
this.toolCallLog.push(entry);
|
|
103
|
-
|
|
104
134
|
if (!this.tools[toolName]) throw new Error(`Unknown tool: ${toolName}`);
|
|
105
135
|
const result = await this.tools[toolName](params);
|
|
106
136
|
entry.status = 'completed';
|
|
@@ -108,70 +138,52 @@ class ACPProtocol extends EventEmitter {
|
|
|
108
138
|
return result;
|
|
109
139
|
}
|
|
110
140
|
|
|
111
|
-
parseTextOutput(output) {
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
parseToolCalls(output) {
|
|
116
|
-
return parseToolCalls(output);
|
|
117
|
-
}
|
|
141
|
+
parseTextOutput(output) { return parseTextOutput(output); }
|
|
142
|
+
parseToolCalls(output) { return parseToolCalls(output); }
|
|
118
143
|
|
|
119
144
|
async process(text, options = {}) {
|
|
120
|
-
const cli = options.cli || 'kilo';
|
|
121
|
-
const model = options.model;
|
|
122
145
|
const fullPrompt = this.instruction
|
|
123
146
|
? `${this.instruction}${this.getToolsPrompt()}\n\n---\n\n${text}`
|
|
124
147
|
: `${this.getToolsPrompt()}\n\n---\n\n${text}`;
|
|
125
148
|
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
137
|
-
cwd: process.cwd(),
|
|
138
|
-
env: { ...process.env },
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
child.stdin.end();
|
|
142
|
-
child.stdout.on('data', (data) => { output += data.toString(); });
|
|
143
|
-
child.stderr.on('data', (data) => { errorOutput += data.toString(); });
|
|
144
|
-
|
|
145
|
-
child.on('close', async (code) => {
|
|
146
|
-
if (code !== 0 && code !== null && !output) {
|
|
147
|
-
reject(new Error(`CLI exited with code ${code}: ${errorOutput}`));
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
const toolCalls = parseToolCalls(output);
|
|
152
|
-
const results = [];
|
|
153
|
-
for (const call of toolCalls) {
|
|
154
|
-
const toolName = call.method.replace('tools/', '');
|
|
155
|
-
if (this.toolWhitelist.has(toolName)) {
|
|
156
|
-
const result = await this.callTool(toolName, call.params);
|
|
157
|
-
results.push({ tool: toolName, result });
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
resolve({ text: parseTextOutput(output), rawOutput: output, toolCalls: results, logs: this.toolCallLog });
|
|
161
|
-
} catch (e) {
|
|
162
|
-
resolve({ text: parseTextOutput(output), rawOutput: output, error: e.message, logs: this.toolCallLog });
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
child.on('error', (error) => {
|
|
167
|
-
reject(new Error(`Failed to spawn ${cli}: ${error.message}`));
|
|
168
|
-
});
|
|
149
|
+
let stack;
|
|
150
|
+
if (options.services) {
|
|
151
|
+
stack = createServiceStack(options.services);
|
|
152
|
+
} else if (options.cli) {
|
|
153
|
+
stack = [{ name: options.cli, profileId: '__default__', config: { cli: options.cli } }];
|
|
154
|
+
} else {
|
|
155
|
+
stack = this.registry.getAll().length > 0
|
|
156
|
+
? this.registry.getAvailable()
|
|
157
|
+
: [{ name: 'kilo', profileId: '__default__', config: { cli: 'kilo' } }];
|
|
158
|
+
}
|
|
169
159
|
|
|
170
|
-
|
|
171
|
-
}
|
|
160
|
+
this._abortController = new AbortController();
|
|
161
|
+
const runOptions = { ...options, _abortSignal: this._abortController.signal };
|
|
162
|
+
const engine = new FallbackEngine(stack);
|
|
163
|
+
engine.on('rate-limited', (e) => this.fallback.emit('rate-limited', e));
|
|
164
|
+
engine.on('fallback', (e) => this.fallback.emit('fallback', e));
|
|
165
|
+
engine.on('success', (e) => this.fallback.emit('success', e));
|
|
166
|
+
|
|
167
|
+
const { rawOutput } = await engine.run(spawnService, fullPrompt, runOptions);
|
|
168
|
+
this._abortController = null;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const toolCalls = parseToolCalls(rawOutput);
|
|
172
|
+
const results = [];
|
|
173
|
+
for (const call of toolCalls) {
|
|
174
|
+
const toolName = call.method.replace('tools/', '');
|
|
175
|
+
if (this.toolWhitelist.has(toolName)) {
|
|
176
|
+
const result = await this.callTool(toolName, call.params);
|
|
177
|
+
results.push({ tool: toolName, result });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { text: parseTextOutput(rawOutput), rawOutput, toolCalls: results, logs: this.toolCallLog };
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return { text: parseTextOutput(rawOutput), rawOutput, error: e.message, logs: this.toolCallLog };
|
|
183
|
+
}
|
|
172
184
|
}
|
|
173
185
|
|
|
174
|
-
stop() {}
|
|
186
|
+
stop() { if (this._abortController) { this._abortController.abort(); this._abortController = null; } }
|
|
175
187
|
}
|
|
176
188
|
|
|
177
189
|
export { ACPProtocol };
|
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, buildArgs, DEFAULT_COOLDOWN_MS } from './services.js';
|
|
3
|
+
import { FallbackEngine } from './fallback.js';
|
|
2
4
|
|
|
3
|
-
export { ACPProtocol };
|
|
5
|
+
export { ACPProtocol, ServiceRegistry, FallbackEngine, isRateLimited, createServiceStack, buildArgs, DEFAULT_COOLDOWN_MS };
|
|
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
|
-
*
|
|
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
|
-
*
|
|
15
|
-
* acp.
|
|
16
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
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 };
|