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 +154 -66
- package/core.js +115 -162
- package/fallback.js +66 -0
- package/index.js +37 -16
- package/package.json +1 -1
- package/parser.js +58 -0
- package/services.js +139 -0
package/README.md
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
# acpreact - ACP SDK
|
|
2
2
|
|
|
3
|
-
A lightweight SDK for
|
|
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
|
-
###
|
|
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
|
-
'
|
|
31
|
-
'
|
|
41
|
+
'add',
|
|
42
|
+
'Add two numbers together',
|
|
32
43
|
{
|
|
33
44
|
type: 'object',
|
|
34
45
|
properties: {
|
|
35
|
-
|
|
46
|
+
a: { type: 'number', description: 'First number' },
|
|
47
|
+
b: { type: 'number', description: 'Second number' }
|
|
36
48
|
},
|
|
37
|
-
required: ['
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
const result = await acp.
|
|
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
|
-
###
|
|
66
|
+
### Multi-Service Fallback
|
|
57
67
|
|
|
58
|
-
Pass a
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
};
|
|
83
|
-
}
|
|
153
|
+
async (params) => ({
|
|
154
|
+
location: params.location,
|
|
155
|
+
temperature: 72,
|
|
156
|
+
condition: 'sunny'
|
|
157
|
+
})
|
|
84
158
|
);
|
|
85
159
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
console.log(
|
|
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
|
|
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
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
199
|
+
- `createJsonRpcResponse(id, result)`: Create JSON-RPC 2.0 response object
|
|
115
200
|
|
|
116
|
-
- `
|
|
201
|
+
- `createJsonRpcError(id, error)`: Create JSON-RPC 2.0 error object (accepts Error or string)
|
|
117
202
|
|
|
118
|
-
- `
|
|
203
|
+
- `validateToolCall(toolName)`: Check if tool is whitelisted, returns `{ allowed, error? }`
|
|
119
204
|
|
|
120
|
-
- `
|
|
205
|
+
- `async callTool(toolName, params)`: Execute a registered tool directly
|
|
121
206
|
|
|
122
|
-
- `
|
|
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
|
|
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.
|
|
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(
|
|
31
|
-
name
|
|
32
|
-
description: this.toolDescriptions[
|
|
33
|
-
inputSchema: this.toolSchemas[
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
reject(new Error(`Failed to spawn ${cli}: ${error.message}`));
|
|
217
|
-
});
|
|
161
|
+
const { rawOutput } = await this.fallback.run(spawnService, fullPrompt, options);
|
|
218
162
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
*
|
|
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/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 };
|