btca-server 1.0.63 → 1.0.71
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 +4 -1
- package/package.json +4 -2
- package/src/agent/agent.test.ts +114 -16
- package/src/agent/loop.ts +14 -11
- package/src/agent/service.ts +117 -86
- package/src/collections/index.ts +0 -0
- package/src/collections/service.ts +187 -57
- package/src/collections/types.ts +1 -0
- package/src/collections/virtual-metadata.ts +32 -0
- package/src/config/config.test.ts +0 -0
- package/src/config/index.ts +195 -127
- package/src/config/remote.ts +132 -79
- package/src/context/index.ts +0 -0
- package/src/context/transaction.ts +20 -15
- package/src/errors.ts +0 -0
- package/src/index.ts +29 -15
- package/src/metrics/index.ts +18 -13
- package/src/providers/auth.ts +38 -11
- package/src/providers/model.ts +3 -1
- package/src/providers/openrouter.ts +39 -0
- package/src/providers/registry.ts +2 -0
- package/src/resources/helpers.ts +0 -0
- package/src/resources/impls/git.test.ts +0 -0
- package/src/resources/impls/git.ts +160 -117
- package/src/resources/index.ts +0 -0
- package/src/resources/schema.ts +24 -27
- package/src/resources/service.ts +0 -0
- package/src/resources/types.ts +0 -0
- package/src/stream/index.ts +0 -0
- package/src/stream/service.ts +23 -14
- package/src/tools/context.ts +4 -0
- package/src/tools/glob.ts +72 -45
- package/src/tools/grep.ts +136 -57
- package/src/tools/index.ts +0 -2
- package/src/tools/list.ts +34 -53
- package/src/tools/read.ts +46 -32
- package/src/tools/virtual-sandbox.ts +103 -0
- package/src/validation/index.ts +12 -12
- package/src/vfs/virtual-fs.test.ts +107 -0
- package/src/vfs/virtual-fs.ts +273 -0
- package/src/tools/ripgrep.ts +0 -348
- package/src/tools/sandbox.ts +0 -164
package/README.md
CHANGED
|
@@ -140,7 +140,6 @@ Example config.toml:
|
|
|
140
140
|
provider = "anthropic"
|
|
141
141
|
model = "claude-3-7-sonnet-20250219"
|
|
142
142
|
resourcesDirectory = "~/.btca/resources"
|
|
143
|
-
collectionsDirectory = "~/.btca/collections"
|
|
144
143
|
|
|
145
144
|
[[resources]]
|
|
146
145
|
type = "local"
|
|
@@ -158,6 +157,10 @@ branch = "main"
|
|
|
158
157
|
|
|
159
158
|
- `PORT`: Server port (default: 8080)
|
|
160
159
|
- `OPENCODE_API_KEY`: OpenCode AI API key (required)
|
|
160
|
+
- `OPENROUTER_API_KEY`: OpenRouter API key (required when provider is `openrouter`)
|
|
161
|
+
- `OPENROUTER_BASE_URL`: Override OpenRouter base URL (optional)
|
|
162
|
+
- `OPENROUTER_HTTP_REFERER`: Optional OpenRouter header for rankings
|
|
163
|
+
- `OPENROUTER_X_TITLE`: Optional OpenRouter header for rankings
|
|
161
164
|
|
|
162
165
|
## TypeScript Types
|
|
163
166
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "btca-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.71",
|
|
4
4
|
"description": "BTCA server for answering questions about your codebase using OpenCode AI",
|
|
5
5
|
"author": "Ben Davis",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
9
|
+
"url": "git+https://github.com/davis7dotsh/better-context.git",
|
|
10
10
|
"directory": "apps/server"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
"@btca/shared": "workspace:*",
|
|
70
70
|
"@opencode-ai/sdk": "^1.1.28",
|
|
71
71
|
"ai": "^6.0.49",
|
|
72
|
+
"better-result": "^2.6.0",
|
|
72
73
|
"hono": "^4.7.11",
|
|
74
|
+
"just-bash": "^2.7.0",
|
|
73
75
|
"opencode-ai": "^1.1.36",
|
|
74
76
|
"zod": "^3.25.76"
|
|
75
77
|
}
|
package/src/agent/agent.test.ts
CHANGED
|
@@ -6,6 +6,11 @@ import os from 'node:os';
|
|
|
6
6
|
import { Agent } from './service.ts';
|
|
7
7
|
import { Config } from '../config/index.ts';
|
|
8
8
|
import type { CollectionResult } from '../collections/types.ts';
|
|
9
|
+
import {
|
|
10
|
+
getVirtualCollectionMetadata,
|
|
11
|
+
setVirtualCollectionMetadata
|
|
12
|
+
} from '../collections/virtual-metadata.ts';
|
|
13
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
9
14
|
|
|
10
15
|
describe('Agent', () => {
|
|
11
16
|
let testDir: string;
|
|
@@ -41,21 +46,22 @@ describe('Agent', () => {
|
|
|
41
46
|
// Run with: BTCA_RUN_INTEGRATION_TESTS=1 bun test
|
|
42
47
|
describe.skipIf(!process.env.BTCA_RUN_INTEGRATION_TESTS)('Agent.ask (integration)', () => {
|
|
43
48
|
it('asks a question and receives an answer', async () => {
|
|
44
|
-
// Create a simple collection directory with a test file
|
|
45
|
-
const collectionPath = path.join(testDir, 'test-collection');
|
|
46
|
-
await fs.mkdir(collectionPath, { recursive: true });
|
|
47
|
-
await fs.writeFile(
|
|
48
|
-
path.join(collectionPath, 'README.md'),
|
|
49
|
-
'# Test Documentation\n\nThis is a test file. The answer to life is 42.'
|
|
50
|
-
);
|
|
51
|
-
|
|
52
49
|
process.chdir(testDir);
|
|
53
50
|
const config = await Config.load();
|
|
54
51
|
const agent = Agent.create(config);
|
|
55
52
|
|
|
53
|
+
const vfsId = VirtualFs.create();
|
|
54
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
55
|
+
await VirtualFs.writeFile(
|
|
56
|
+
'/README.md',
|
|
57
|
+
'# Test Documentation\n\nThis is a test file. The answer to life is 42.',
|
|
58
|
+
vfsId
|
|
59
|
+
);
|
|
60
|
+
|
|
56
61
|
const collection: CollectionResult = {
|
|
57
|
-
path:
|
|
58
|
-
agentInstructions: 'This is a test collection with a README file.'
|
|
62
|
+
path: '/',
|
|
63
|
+
agentInstructions: 'This is a test collection with a README file.',
|
|
64
|
+
vfsId
|
|
59
65
|
};
|
|
60
66
|
|
|
61
67
|
const result = await agent.ask({
|
|
@@ -75,17 +81,18 @@ describe('Agent', () => {
|
|
|
75
81
|
}, 60000);
|
|
76
82
|
|
|
77
83
|
it('handles askStream and receives events', async () => {
|
|
78
|
-
const collectionPath = path.join(testDir, 'stream-collection');
|
|
79
|
-
await fs.mkdir(collectionPath, { recursive: true });
|
|
80
|
-
await fs.writeFile(path.join(collectionPath, 'data.txt'), 'The capital of France is Paris.');
|
|
81
|
-
|
|
82
84
|
process.chdir(testDir);
|
|
83
85
|
const config = await Config.load();
|
|
84
86
|
const agent = Agent.create(config);
|
|
85
87
|
|
|
88
|
+
const vfsId = VirtualFs.create();
|
|
89
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
90
|
+
await VirtualFs.writeFile('/data.txt', 'The capital of France is Paris.', vfsId);
|
|
91
|
+
|
|
86
92
|
const collection: CollectionResult = {
|
|
87
|
-
path:
|
|
88
|
-
agentInstructions: 'Simple test collection.'
|
|
93
|
+
path: '/',
|
|
94
|
+
agentInstructions: 'Simple test collection.',
|
|
95
|
+
vfsId
|
|
89
96
|
};
|
|
90
97
|
|
|
91
98
|
const { stream, model } = await agent.askStream({
|
|
@@ -107,5 +114,96 @@ describe('Agent', () => {
|
|
|
107
114
|
const textEvents = events.filter((e) => e.type === 'text-delta');
|
|
108
115
|
expect(textEvents.length).toBeGreaterThan(0);
|
|
109
116
|
}, 60000);
|
|
117
|
+
|
|
118
|
+
it('cleans up virtual collections after ask', async () => {
|
|
119
|
+
process.chdir(testDir);
|
|
120
|
+
const config = await Config.load();
|
|
121
|
+
const agent = Agent.create(config);
|
|
122
|
+
|
|
123
|
+
const vfsId = VirtualFs.create();
|
|
124
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
125
|
+
await VirtualFs.mkdir('/docs', { recursive: true }, vfsId);
|
|
126
|
+
await VirtualFs.writeFile('/docs/README.md', 'Virtual README\nThe answer is 42.', vfsId);
|
|
127
|
+
|
|
128
|
+
setVirtualCollectionMetadata({
|
|
129
|
+
vfsId,
|
|
130
|
+
collectionKey: 'virtual-test',
|
|
131
|
+
createdAt: new Date().toISOString(),
|
|
132
|
+
resources: [
|
|
133
|
+
{
|
|
134
|
+
name: 'docs',
|
|
135
|
+
fsName: 'docs',
|
|
136
|
+
type: 'local',
|
|
137
|
+
path: '/docs',
|
|
138
|
+
repoSubPaths: [],
|
|
139
|
+
loadedAt: new Date().toISOString()
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const collection: CollectionResult = {
|
|
145
|
+
path: '/',
|
|
146
|
+
agentInstructions: 'This is a virtual collection with a README file.',
|
|
147
|
+
vfsId
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = await agent.ask({
|
|
151
|
+
collection,
|
|
152
|
+
question: 'What number is the answer according to the README?'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result).toBeDefined();
|
|
156
|
+
expect(VirtualFs.has(vfsId)).toBe(false);
|
|
157
|
+
expect(getVirtualCollectionMetadata(vfsId)).toBeUndefined();
|
|
158
|
+
}, 60000);
|
|
159
|
+
|
|
160
|
+
it('cleans up virtual collections after askStream', async () => {
|
|
161
|
+
process.chdir(testDir);
|
|
162
|
+
const config = await Config.load();
|
|
163
|
+
const agent = Agent.create(config);
|
|
164
|
+
|
|
165
|
+
const vfsId = VirtualFs.create();
|
|
166
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
167
|
+
await VirtualFs.mkdir('/docs', { recursive: true }, vfsId);
|
|
168
|
+
await VirtualFs.writeFile(
|
|
169
|
+
'/docs/README.md',
|
|
170
|
+
'Virtual README\nThe capital of France is Paris.',
|
|
171
|
+
vfsId
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
setVirtualCollectionMetadata({
|
|
175
|
+
vfsId,
|
|
176
|
+
collectionKey: 'virtual-stream-test',
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
resources: [
|
|
179
|
+
{
|
|
180
|
+
name: 'docs',
|
|
181
|
+
fsName: 'docs',
|
|
182
|
+
type: 'local',
|
|
183
|
+
path: '/docs',
|
|
184
|
+
repoSubPaths: [],
|
|
185
|
+
loadedAt: new Date().toISOString()
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const collection: CollectionResult = {
|
|
191
|
+
path: '/',
|
|
192
|
+
agentInstructions: 'This is a virtual collection with a README file.',
|
|
193
|
+
vfsId
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const { stream } = await agent.askStream({
|
|
197
|
+
collection,
|
|
198
|
+
question: 'What is the capital of France according to the README?'
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for await (const _event of stream) {
|
|
202
|
+
// drain stream to trigger cleanup
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(VirtualFs.has(vfsId)).toBe(false);
|
|
206
|
+
expect(getVirtualCollectionMetadata(vfsId)).toBeUndefined();
|
|
207
|
+
}, 60000);
|
|
110
208
|
});
|
|
111
209
|
});
|
package/src/agent/loop.ts
CHANGED
|
@@ -25,6 +25,7 @@ export namespace AgentLoop {
|
|
|
25
25
|
providerId: string;
|
|
26
26
|
modelId: string;
|
|
27
27
|
collectionPath: string;
|
|
28
|
+
vfsId?: string;
|
|
28
29
|
agentInstructions: string;
|
|
29
30
|
question: string;
|
|
30
31
|
maxSteps?: number;
|
|
@@ -65,13 +66,13 @@ export namespace AgentLoop {
|
|
|
65
66
|
/**
|
|
66
67
|
* Create the tools for the agent
|
|
67
68
|
*/
|
|
68
|
-
function createTools(basePath: string) {
|
|
69
|
+
function createTools(basePath: string, vfsId?: string) {
|
|
69
70
|
return {
|
|
70
71
|
read: tool({
|
|
71
72
|
description: 'Read the contents of a file. Returns the file contents with line numbers.',
|
|
72
73
|
inputSchema: ReadTool.Parameters,
|
|
73
74
|
execute: async (params: ReadTool.ParametersType) => {
|
|
74
|
-
const result = await ReadTool.execute(params, { basePath });
|
|
75
|
+
const result = await ReadTool.execute(params, { basePath, vfsId });
|
|
75
76
|
return result.output;
|
|
76
77
|
}
|
|
77
78
|
}),
|
|
@@ -81,7 +82,7 @@ export namespace AgentLoop {
|
|
|
81
82
|
'Search for a regex pattern in file contents. Returns matching lines with file paths and line numbers.',
|
|
82
83
|
inputSchema: GrepTool.Parameters,
|
|
83
84
|
execute: async (params: GrepTool.ParametersType) => {
|
|
84
|
-
const result = await GrepTool.execute(params, { basePath });
|
|
85
|
+
const result = await GrepTool.execute(params, { basePath, vfsId });
|
|
85
86
|
return result.output;
|
|
86
87
|
}
|
|
87
88
|
}),
|
|
@@ -91,7 +92,7 @@ export namespace AgentLoop {
|
|
|
91
92
|
'Find files matching a glob pattern (e.g. "**/*.ts", "src/**/*.js"). Returns a list of matching file paths sorted by modification time.',
|
|
92
93
|
inputSchema: GlobTool.Parameters,
|
|
93
94
|
execute: async (params: GlobTool.ParametersType) => {
|
|
94
|
-
const result = await GlobTool.execute(params, { basePath });
|
|
95
|
+
const result = await GlobTool.execute(params, { basePath, vfsId });
|
|
95
96
|
return result.output;
|
|
96
97
|
}
|
|
97
98
|
}),
|
|
@@ -101,7 +102,7 @@ export namespace AgentLoop {
|
|
|
101
102
|
'List the contents of a directory. Returns files and subdirectories with their types.',
|
|
102
103
|
inputSchema: ListTool.Parameters,
|
|
103
104
|
execute: async (params: ListTool.ParametersType) => {
|
|
104
|
-
const result = await ListTool.execute(params, { basePath });
|
|
105
|
+
const result = await ListTool.execute(params, { basePath, vfsId });
|
|
105
106
|
return result.output;
|
|
106
107
|
}
|
|
107
108
|
})
|
|
@@ -111,8 +112,8 @@ export namespace AgentLoop {
|
|
|
111
112
|
/**
|
|
112
113
|
* Get initial context by listing the collection directory
|
|
113
114
|
*/
|
|
114
|
-
async function getInitialContext(collectionPath: string
|
|
115
|
-
const result = await ListTool.execute({ path: '.' }, { basePath: collectionPath });
|
|
115
|
+
async function getInitialContext(collectionPath: string, vfsId?: string) {
|
|
116
|
+
const result = await ListTool.execute({ path: '.' }, { basePath: collectionPath, vfsId });
|
|
116
117
|
return `Collection contents:\n${result.output}`;
|
|
117
118
|
}
|
|
118
119
|
|
|
@@ -124,6 +125,7 @@ export namespace AgentLoop {
|
|
|
124
125
|
providerId,
|
|
125
126
|
modelId,
|
|
126
127
|
collectionPath,
|
|
128
|
+
vfsId,
|
|
127
129
|
agentInstructions,
|
|
128
130
|
question,
|
|
129
131
|
maxSteps = 40
|
|
@@ -133,7 +135,7 @@ export namespace AgentLoop {
|
|
|
133
135
|
const model = await Model.getModel(providerId, modelId);
|
|
134
136
|
|
|
135
137
|
// Get initial context
|
|
136
|
-
const initialContext = await getInitialContext(collectionPath);
|
|
138
|
+
const initialContext = await getInitialContext(collectionPath, vfsId);
|
|
137
139
|
|
|
138
140
|
// Build messages
|
|
139
141
|
const messages: ModelMessage[] = [
|
|
@@ -144,7 +146,7 @@ export namespace AgentLoop {
|
|
|
144
146
|
];
|
|
145
147
|
|
|
146
148
|
// Create tools
|
|
147
|
-
const tools = createTools(collectionPath);
|
|
149
|
+
const tools = createTools(collectionPath, vfsId);
|
|
148
150
|
|
|
149
151
|
// Collect events
|
|
150
152
|
const events: AgentEvent[] = [];
|
|
@@ -218,6 +220,7 @@ export namespace AgentLoop {
|
|
|
218
220
|
providerId,
|
|
219
221
|
modelId,
|
|
220
222
|
collectionPath,
|
|
223
|
+
vfsId,
|
|
221
224
|
agentInstructions,
|
|
222
225
|
question,
|
|
223
226
|
maxSteps = 40
|
|
@@ -227,7 +230,7 @@ export namespace AgentLoop {
|
|
|
227
230
|
const model = await Model.getModel(providerId, modelId);
|
|
228
231
|
|
|
229
232
|
// Get initial context
|
|
230
|
-
const initialContext = await getInitialContext(collectionPath);
|
|
233
|
+
const initialContext = await getInitialContext(collectionPath, vfsId);
|
|
231
234
|
|
|
232
235
|
// Build messages
|
|
233
236
|
const messages: ModelMessage[] = [
|
|
@@ -238,7 +241,7 @@ export namespace AgentLoop {
|
|
|
238
241
|
];
|
|
239
242
|
|
|
240
243
|
// Create tools
|
|
241
|
-
const tools = createTools(collectionPath);
|
|
244
|
+
const tools = createTools(collectionPath, vfsId);
|
|
242
245
|
|
|
243
246
|
// Run streamText with tool execution
|
|
244
247
|
const result = streamText({
|
package/src/agent/service.ts
CHANGED
|
@@ -8,12 +8,15 @@ import {
|
|
|
8
8
|
type Config as OpenCodeConfig,
|
|
9
9
|
type OpencodeClient
|
|
10
10
|
} from '@opencode-ai/sdk';
|
|
11
|
+
import { Result } from 'better-result';
|
|
11
12
|
|
|
12
13
|
import { Config } from '../config/index.ts';
|
|
13
14
|
import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
|
|
14
15
|
import { Metrics } from '../metrics/index.ts';
|
|
15
16
|
import { Auth, getSupportedProviders } from '../providers/index.ts';
|
|
16
17
|
import type { CollectionResult } from '../collections/types.ts';
|
|
18
|
+
import { clearVirtualCollectionMetadata } from '../collections/virtual-metadata.ts';
|
|
19
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
17
20
|
import type { AgentResult, TrackedInstance, InstanceInfo } from './types.ts';
|
|
18
21
|
import { AgentLoop } from './loop.ts';
|
|
19
22
|
|
|
@@ -110,10 +113,14 @@ export namespace Agent {
|
|
|
110
113
|
super(`Provider "${args.providerId}" is not connected`);
|
|
111
114
|
this.providerId = args.providerId;
|
|
112
115
|
this.connectedProviders = args.connectedProviders;
|
|
116
|
+
const baseHint =
|
|
117
|
+
args.providerId === 'openrouter'
|
|
118
|
+
? 'Set OPENROUTER_API_KEY to authenticate OpenRouter.'
|
|
119
|
+
: CommonHints.RUN_AUTH;
|
|
113
120
|
if (args.connectedProviders.length > 0) {
|
|
114
|
-
this.hint = `${
|
|
121
|
+
this.hint = `${baseHint} Connected providers: ${args.connectedProviders.join(', ')}.`;
|
|
115
122
|
} else {
|
|
116
|
-
this.hint = `${
|
|
123
|
+
this.hint = `${baseHint} No providers are currently connected.`;
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
126
|
}
|
|
@@ -226,11 +233,11 @@ export namespace Agent {
|
|
|
226
233
|
server: { close(): void; url: string };
|
|
227
234
|
baseUrl: string;
|
|
228
235
|
}> => {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
const tryCreateOpencode = async (port: number) => {
|
|
237
|
+
const result = await Result.tryPromise(() => createOpencode({ port, config: args.ocConfig }));
|
|
238
|
+
return result.match({
|
|
239
|
+
ok: (created) => created,
|
|
240
|
+
err: (err) => {
|
|
234
241
|
const error = err as { cause?: Error };
|
|
235
242
|
if (error?.cause instanceof Error && error.cause.stack?.includes('port')) return null;
|
|
236
243
|
throw new AgentError({
|
|
@@ -239,7 +246,13 @@ export namespace Agent {
|
|
|
239
246
|
cause: err
|
|
240
247
|
});
|
|
241
248
|
}
|
|
242
|
-
);
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const maxAttempts = 10;
|
|
253
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
254
|
+
const port = Math.floor(Math.random() * 3000) + 3000;
|
|
255
|
+
const created = await tryCreateOpencode(port);
|
|
243
256
|
|
|
244
257
|
if (created) {
|
|
245
258
|
const baseUrl = `http://localhost:${port}`;
|
|
@@ -272,10 +285,17 @@ export namespace Agent {
|
|
|
272
285
|
questionLength: question.length
|
|
273
286
|
});
|
|
274
287
|
|
|
288
|
+
const cleanup = () => {
|
|
289
|
+
if (!collection.vfsId) return;
|
|
290
|
+
VirtualFs.dispose(collection.vfsId);
|
|
291
|
+
clearVirtualCollectionMetadata(collection.vfsId);
|
|
292
|
+
};
|
|
293
|
+
|
|
275
294
|
// Validate provider is authenticated
|
|
276
295
|
const isAuthed = await Auth.isAuthenticated(config.provider);
|
|
277
296
|
if (!isAuthed && config.provider !== 'opencode') {
|
|
278
297
|
const authenticated = await Auth.getAuthenticatedProviders();
|
|
298
|
+
cleanup();
|
|
279
299
|
throw new ProviderNotConnectedError({
|
|
280
300
|
providerId: config.provider,
|
|
281
301
|
connectedProviders: authenticated
|
|
@@ -283,13 +303,23 @@ export namespace Agent {
|
|
|
283
303
|
}
|
|
284
304
|
|
|
285
305
|
// Create a generator that wraps the AgentLoop stream
|
|
286
|
-
const eventGenerator =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
const eventGenerator = (async function* () {
|
|
307
|
+
try {
|
|
308
|
+
const stream = AgentLoop.stream({
|
|
309
|
+
providerId: config.provider,
|
|
310
|
+
modelId: config.model,
|
|
311
|
+
collectionPath: collection.path,
|
|
312
|
+
vfsId: collection.vfsId,
|
|
313
|
+
agentInstructions: collection.agentInstructions,
|
|
314
|
+
question
|
|
315
|
+
});
|
|
316
|
+
for await (const event of stream) {
|
|
317
|
+
yield event;
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
cleanup();
|
|
321
|
+
}
|
|
322
|
+
})();
|
|
293
323
|
|
|
294
324
|
return {
|
|
295
325
|
stream: eventGenerator,
|
|
@@ -307,45 +337,60 @@ export namespace Agent {
|
|
|
307
337
|
questionLength: question.length
|
|
308
338
|
});
|
|
309
339
|
|
|
340
|
+
const cleanup = () => {
|
|
341
|
+
if (!collection.vfsId) return;
|
|
342
|
+
VirtualFs.dispose(collection.vfsId);
|
|
343
|
+
clearVirtualCollectionMetadata(collection.vfsId);
|
|
344
|
+
};
|
|
345
|
+
|
|
310
346
|
// Validate provider is authenticated
|
|
311
347
|
const isAuthed = await Auth.isAuthenticated(config.provider);
|
|
312
348
|
if (!isAuthed && config.provider !== 'opencode') {
|
|
313
349
|
const authenticated = await Auth.getAuthenticatedProviders();
|
|
350
|
+
cleanup();
|
|
314
351
|
throw new ProviderNotConnectedError({
|
|
315
352
|
providerId: config.provider,
|
|
316
353
|
connectedProviders: authenticated
|
|
317
354
|
});
|
|
318
355
|
}
|
|
319
356
|
|
|
320
|
-
|
|
321
|
-
|
|
357
|
+
const runResult = await Result.tryPromise(() =>
|
|
358
|
+
AgentLoop.run({
|
|
322
359
|
providerId: config.provider,
|
|
323
360
|
modelId: config.model,
|
|
324
361
|
collectionPath: collection.path,
|
|
362
|
+
vfsId: collection.vfsId,
|
|
325
363
|
agentInstructions: collection.agentInstructions,
|
|
326
364
|
question
|
|
327
|
-
})
|
|
365
|
+
})
|
|
366
|
+
);
|
|
328
367
|
|
|
329
|
-
|
|
330
|
-
provider: config.provider,
|
|
331
|
-
model: config.model,
|
|
332
|
-
answerLength: result.answer.length,
|
|
333
|
-
eventCount: result.events.length
|
|
334
|
-
});
|
|
368
|
+
cleanup();
|
|
335
369
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
370
|
+
return runResult.match({
|
|
371
|
+
ok: (result) => {
|
|
372
|
+
Metrics.info('agent.ask.complete', {
|
|
373
|
+
provider: config.provider,
|
|
374
|
+
model: config.model,
|
|
375
|
+
answerLength: result.answer.length,
|
|
376
|
+
eventCount: result.events.length
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
answer: result.answer,
|
|
381
|
+
model: result.model,
|
|
382
|
+
events: result.events
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
err: (error) => {
|
|
386
|
+
Metrics.error('agent.ask.error', { error: Metrics.errorInfo(error) });
|
|
387
|
+
throw new AgentError({
|
|
388
|
+
message: 'Failed to get response from AI',
|
|
389
|
+
hint: 'This may be a temporary issue. Try running the command again.',
|
|
390
|
+
cause: error
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
});
|
|
349
394
|
};
|
|
350
395
|
|
|
351
396
|
/**
|
|
@@ -353,31 +398,11 @@ export namespace Agent {
|
|
|
353
398
|
* This still spawns a full OpenCode instance for clients that need it
|
|
354
399
|
*/
|
|
355
400
|
const getOpencodeInstance: Service['getOpencodeInstance'] = async ({ collection }) => {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
});
|
|
361
|
-
const { server, baseUrl } = await createOpencodeInstance({
|
|
362
|
-
collectionPath: collection.path,
|
|
363
|
-
ocConfig
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
// Register the instance for lifecycle management
|
|
367
|
-
const instanceId = generateInstanceId();
|
|
368
|
-
registerInstance(instanceId, server, collection.path);
|
|
369
|
-
|
|
370
|
-
Metrics.info('agent.oc.instance.ready', {
|
|
371
|
-
baseUrl,
|
|
372
|
-
collectionPath: collection.path,
|
|
373
|
-
instanceId
|
|
401
|
+
throw new AgentError({
|
|
402
|
+
message: 'OpenCode instance not available',
|
|
403
|
+
hint: 'BTCA uses virtual collections only. Use the btca ask/stream APIs instead.',
|
|
404
|
+
cause: new Error('Virtual collections are not compatible with filesystem-based OpenCode')
|
|
374
405
|
});
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
url: baseUrl,
|
|
378
|
-
model: { provider: config.provider, model: config.model },
|
|
379
|
-
instanceId
|
|
380
|
-
};
|
|
381
406
|
};
|
|
382
407
|
|
|
383
408
|
/**
|
|
@@ -413,20 +438,23 @@ export namespace Agent {
|
|
|
413
438
|
return { closed: false };
|
|
414
439
|
}
|
|
415
440
|
|
|
416
|
-
try
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
441
|
+
const closeResult = Result.try(() => instance.server.close());
|
|
442
|
+
return closeResult.match({
|
|
443
|
+
ok: () => {
|
|
444
|
+
unregisterInstance(instanceId);
|
|
445
|
+
Metrics.info('agent.instance.closed', { instanceId });
|
|
446
|
+
return { closed: true };
|
|
447
|
+
},
|
|
448
|
+
err: (cause) => {
|
|
449
|
+
Metrics.error('agent.instance.close.err', {
|
|
450
|
+
instanceId,
|
|
451
|
+
error: Metrics.errorInfo(cause)
|
|
452
|
+
});
|
|
453
|
+
// Still remove from registry even if close failed
|
|
454
|
+
unregisterInstance(instanceId);
|
|
455
|
+
return { closed: true };
|
|
456
|
+
}
|
|
457
|
+
});
|
|
430
458
|
};
|
|
431
459
|
|
|
432
460
|
/**
|
|
@@ -450,17 +478,20 @@ export namespace Agent {
|
|
|
450
478
|
let closed = 0;
|
|
451
479
|
|
|
452
480
|
for (const instance of instances) {
|
|
453
|
-
try
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
481
|
+
const closeResult = Result.try(() => instance.server.close());
|
|
482
|
+
closeResult.match({
|
|
483
|
+
ok: () => {
|
|
484
|
+
closed++;
|
|
485
|
+
},
|
|
486
|
+
err: (cause) => {
|
|
487
|
+
Metrics.error('agent.instance.close.err', {
|
|
488
|
+
instanceId: instance.id,
|
|
489
|
+
error: Metrics.errorInfo(cause)
|
|
490
|
+
});
|
|
491
|
+
// Count as closed even if there was an error
|
|
492
|
+
closed++;
|
|
493
|
+
}
|
|
494
|
+
});
|
|
464
495
|
}
|
|
465
496
|
|
|
466
497
|
instanceRegistry.clear();
|
package/src/collections/index.ts
CHANGED
|
File without changes
|