btca-server 2.0.2 → 2.0.4
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/package.json +1 -1
- package/src/agent/agent.test.ts +25 -16
- package/src/agent/loop.ts +50 -28
- package/src/agent/service.ts +100 -110
- package/src/collections/service.test.ts +256 -0
- package/src/collections/service.ts +166 -60
- package/src/config/config.test.ts +21 -16
- package/src/config/index.ts +179 -200
- package/src/effect/services.ts +15 -23
- package/src/resources/service.ts +45 -44
- package/src/vfs/virtual-fs.ts +73 -0
package/package.json
CHANGED
package/src/agent/agent.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
+
import { Effect } from 'effect';
|
|
5
6
|
|
|
6
7
|
import { createAgentService } from './service.ts';
|
|
7
8
|
import { load as loadConfig } from '../config/index.ts';
|
|
@@ -71,10 +72,12 @@ describe('Agent', () => {
|
|
|
71
72
|
vfsId
|
|
72
73
|
};
|
|
73
74
|
|
|
74
|
-
const result = await
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
const result = await Effect.runPromise(
|
|
76
|
+
agent.ask({
|
|
77
|
+
collection,
|
|
78
|
+
question: 'What number is the answer to life according to the README?'
|
|
79
|
+
})
|
|
80
|
+
);
|
|
78
81
|
|
|
79
82
|
expect(result).toBeDefined();
|
|
80
83
|
expect(result.answer).toBeDefined();
|
|
@@ -102,10 +105,12 @@ describe('Agent', () => {
|
|
|
102
105
|
vfsId
|
|
103
106
|
};
|
|
104
107
|
|
|
105
|
-
const { stream, model } = await
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
const { stream, model } = await Effect.runPromise(
|
|
109
|
+
agent.askStream({
|
|
110
|
+
collection,
|
|
111
|
+
question: 'What is the capital of France according to the data file?'
|
|
112
|
+
})
|
|
113
|
+
);
|
|
109
114
|
|
|
110
115
|
expect(model).toBeDefined();
|
|
111
116
|
expect(model.provider).toBeDefined();
|
|
@@ -154,10 +159,12 @@ describe('Agent', () => {
|
|
|
154
159
|
vfsId
|
|
155
160
|
};
|
|
156
161
|
|
|
157
|
-
const result = await
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
const result = await Effect.runPromise(
|
|
163
|
+
agent.ask({
|
|
164
|
+
collection,
|
|
165
|
+
question: 'What number is the answer according to the README?'
|
|
166
|
+
})
|
|
167
|
+
);
|
|
161
168
|
|
|
162
169
|
expect(result).toBeDefined();
|
|
163
170
|
expect(hasVirtualFs(vfsId)).toBe(false);
|
|
@@ -200,10 +207,12 @@ describe('Agent', () => {
|
|
|
200
207
|
vfsId
|
|
201
208
|
};
|
|
202
209
|
|
|
203
|
-
const { stream } = await
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
210
|
+
const { stream } = await Effect.runPromise(
|
|
211
|
+
agent.askStream({
|
|
212
|
+
collection,
|
|
213
|
+
question: 'What is the capital of France according to the README?'
|
|
214
|
+
})
|
|
215
|
+
);
|
|
207
216
|
|
|
208
217
|
for await (const _event of stream) {
|
|
209
218
|
// drain stream to trigger cleanup
|
package/src/agent/loop.ts
CHANGED
|
@@ -57,35 +57,57 @@ export type AgentLoopResult = {
|
|
|
57
57
|
events: AgentEvent[];
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
const BASE_PROMPT = `
|
|
61
|
+
You are btca, an expert research agent. Your job is to answer questions from the user by searching the resources at your disposal.
|
|
62
|
+
|
|
63
|
+
<personality_and_writing_controls>
|
|
64
|
+
- Persona: an expert professional researcher
|
|
65
|
+
- Channel: internal
|
|
66
|
+
- Emotional register: direct, calm, and concise
|
|
67
|
+
- Formatting: bulleted/numbered lists are good + codeblocks
|
|
68
|
+
- Length: be thorough with your response, don't let it get too long though
|
|
69
|
+
- Default follow-through: don't ask permission to do the research, just do it and answer the question. ask for clarifications + suggest good follow up if needed
|
|
70
|
+
</personality_and_writing_controls>
|
|
71
|
+
|
|
72
|
+
<parallel_tool_calling>
|
|
73
|
+
- When multiple retrieval or lookup steps are independent, prefer parallel tool calls to reduce wall-clock time.
|
|
74
|
+
- Do not parallelize steps that have prerequisite dependencies or where one result determines the next action.
|
|
75
|
+
- After parallel retrieval, pause to synthesize the results before making more calls.
|
|
76
|
+
- Prefer selective parallelism: parallelize independent evidence gathering, not speculative or redundant tool use.
|
|
77
|
+
</parallel_tool_calling>
|
|
78
|
+
|
|
79
|
+
<tool_persistence_rules>
|
|
80
|
+
- Use tools whenever they materially improve correctness, completeness, or grounding.
|
|
81
|
+
- Do NOT stop early to save tool calls.
|
|
82
|
+
- Keep calling tools until either:
|
|
83
|
+
1) the task is complete
|
|
84
|
+
2) you've hit a doom loop where none of the tools function or something is missing
|
|
85
|
+
- If a tool returns empty/partial results, retry with a different strategy (query, filters, alternate source).
|
|
86
|
+
</tool_persistence_rules>
|
|
87
|
+
|
|
88
|
+
<completeness_contract>
|
|
89
|
+
- Treat the task as incomplete until you have a complete answer to the user's question that's grounded
|
|
90
|
+
- If any item is blocked by missing data, mark it [blocked] and state exactly what is missing.
|
|
91
|
+
</completeness_contract>
|
|
92
|
+
|
|
93
|
+
<dig_deeper_nudge>
|
|
94
|
+
- Don't stop at the first plausible answer.
|
|
95
|
+
- Look for second-order issues, edge cases, and missing constraints.
|
|
96
|
+
</dig_deeper_nudge>
|
|
97
|
+
|
|
98
|
+
<output_contract>
|
|
99
|
+
- Return a thorough answer to the user's question with real code examples
|
|
100
|
+
- Always output in proper markdown format
|
|
101
|
+
- Always include sources for your answer:
|
|
102
|
+
- For git resources, source links must be full github blob urls
|
|
103
|
+
- In "Sources", format git citations as markdown links: - [repo/relative/path.ext](https://github.com/.../blob/.../repo/relative/path.ext)".'
|
|
104
|
+
- For local resources cite local file paths
|
|
105
|
+
- For npm resources cite the path in the npm package
|
|
106
|
+
</output_contract>
|
|
107
|
+
`;
|
|
108
|
+
|
|
60
109
|
const buildSystemPrompt = (agentInstructions: string): string =>
|
|
61
|
-
[
|
|
62
|
-
'You are btca, an expert documentation search agent.',
|
|
63
|
-
'Your job is to answer questions by searching through the collection of resources.',
|
|
64
|
-
'',
|
|
65
|
-
'You have access to the following tools:',
|
|
66
|
-
'- read: Read file contents with line numbers',
|
|
67
|
-
'- grep: Search file contents using regex patterns',
|
|
68
|
-
'- glob: Find files matching glob patterns',
|
|
69
|
-
'- list: List directory contents',
|
|
70
|
-
'',
|
|
71
|
-
'Guidelines:',
|
|
72
|
-
'- Ground answers in the loaded resources. Do not rely on unstated prior knowledge.',
|
|
73
|
-
'- Search efficiently: start with one focused list/glob pass, then read likely files; only expand search when evidence is insufficient.',
|
|
74
|
-
'- Prefer targeted grep/read over broad repeated scans once candidate files are known.',
|
|
75
|
-
'- Give clear, unambiguous answers. State assumptions, prerequisites, and important version-sensitive caveats.',
|
|
76
|
-
'- For implementation/how-to questions, provide complete step-by-step instructions with commands and code snippets.',
|
|
77
|
-
'- Be concise but thorough in your responses.',
|
|
78
|
-
'- End every answer with a "Sources" section.',
|
|
79
|
-
'- For git resources, source links must be full GitHub blob URLs.',
|
|
80
|
-
'- In "Sources", format git citations as markdown links: "- [repo/relative/path.ext](https://github.com/.../blob/.../repo/relative/path.ext)".',
|
|
81
|
-
'- Do not use raw URLs as link labels.',
|
|
82
|
-
'- Do not repeat a URL in parentheses after a link.',
|
|
83
|
-
'- Do not output sources in "url (url)" format.',
|
|
84
|
-
'- For local resources, cite local file paths (no GitHub URL required).',
|
|
85
|
-
'- If you cannot find the answer, say so clearly',
|
|
86
|
-
'',
|
|
87
|
-
agentInstructions
|
|
88
|
-
].join('\n');
|
|
110
|
+
[BASE_PROMPT, agentInstructions].join('\n');
|
|
89
111
|
|
|
90
112
|
const createTools = (basePath: string, vfsId?: string) => ({
|
|
91
113
|
read: tool({
|
package/src/agent/service.ts
CHANGED
|
@@ -87,11 +87,7 @@ export class ProviderNotConnectedError extends Error {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export type AgentService = {
|
|
90
|
-
askStream: (args: { collection: CollectionResult; question: string }) =>
|
|
91
|
-
stream: AsyncIterable<AgentEvent>;
|
|
92
|
-
model: { provider: string; model: string };
|
|
93
|
-
}>;
|
|
94
|
-
askStreamEffect: (args: { collection: CollectionResult; question: string }) => Effect.Effect<
|
|
90
|
+
askStream: (args: { collection: CollectionResult; question: string }) => Effect.Effect<
|
|
95
91
|
{
|
|
96
92
|
stream: AsyncIterable<AgentEvent>;
|
|
97
93
|
model: { provider: string; model: string };
|
|
@@ -99,17 +95,12 @@ export type AgentService = {
|
|
|
99
95
|
unknown
|
|
100
96
|
>;
|
|
101
97
|
|
|
102
|
-
ask: (args: {
|
|
103
|
-
askEffect: (args: {
|
|
98
|
+
ask: (args: {
|
|
104
99
|
collection: CollectionResult;
|
|
105
100
|
question: string;
|
|
106
101
|
}) => Effect.Effect<AgentResult, unknown>;
|
|
107
102
|
|
|
108
|
-
listProviders: () =>
|
|
109
|
-
all: { id: string; models: Record<string, unknown> }[];
|
|
110
|
-
connected: string[];
|
|
111
|
-
}>;
|
|
112
|
-
listProvidersEffect: () => Effect.Effect<
|
|
103
|
+
listProviders: () => Effect.Effect<
|
|
113
104
|
{
|
|
114
105
|
all: { id: string; models: Record<string, unknown> }[];
|
|
115
106
|
connected: string[];
|
|
@@ -121,36 +112,39 @@ export type AgentService = {
|
|
|
121
112
|
export type Service = AgentService;
|
|
122
113
|
|
|
123
114
|
export const createAgentService = (config: ConfigServiceShape): AgentService => {
|
|
124
|
-
const cleanupCollection = (collection: CollectionResult) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
115
|
+
const cleanupCollection = async (collection: CollectionResult) => {
|
|
116
|
+
if (collection.vfsId) {
|
|
117
|
+
disposeVirtualFs(collection.vfsId);
|
|
118
|
+
clearVirtualCollectionMetadata(collection.vfsId);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await collection.cleanup?.();
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
136
126
|
|
|
137
|
-
const ensureProviderConnected =
|
|
138
|
-
const isAuthed =
|
|
127
|
+
const ensureProviderConnected = async () => {
|
|
128
|
+
const isAuthed = await isAuthenticated(config.provider);
|
|
139
129
|
const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
|
|
140
130
|
if (isAuthed || !requiresAuth) return;
|
|
141
|
-
const authenticated =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
);
|
|
148
|
-
});
|
|
131
|
+
const authenticated = await getAuthenticatedProviders();
|
|
132
|
+
throw new ProviderNotConnectedError({
|
|
133
|
+
providerId: config.provider,
|
|
134
|
+
connectedProviders: authenticated
|
|
135
|
+
});
|
|
136
|
+
};
|
|
149
137
|
|
|
150
138
|
/**
|
|
151
139
|
* Ask a question and stream the response using the new AI SDK loop
|
|
152
140
|
*/
|
|
153
|
-
const
|
|
141
|
+
const askStreamImpl = async ({
|
|
142
|
+
collection,
|
|
143
|
+
question
|
|
144
|
+
}: {
|
|
145
|
+
collection: CollectionResult;
|
|
146
|
+
question: string;
|
|
147
|
+
}) => {
|
|
154
148
|
metricsInfo('agent.ask.start', {
|
|
155
149
|
provider: config.provider,
|
|
156
150
|
model: config.model,
|
|
@@ -158,9 +152,9 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
|
|
|
158
152
|
});
|
|
159
153
|
|
|
160
154
|
try {
|
|
161
|
-
await
|
|
155
|
+
await ensureProviderConnected();
|
|
162
156
|
} catch (error) {
|
|
163
|
-
await
|
|
157
|
+
await cleanupCollection(collection);
|
|
164
158
|
throw error;
|
|
165
159
|
}
|
|
166
160
|
|
|
@@ -181,7 +175,7 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
|
|
|
181
175
|
yield event;
|
|
182
176
|
}
|
|
183
177
|
} finally {
|
|
184
|
-
await
|
|
178
|
+
await cleanupCollection(collection);
|
|
185
179
|
}
|
|
186
180
|
})();
|
|
187
181
|
|
|
@@ -194,103 +188,99 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
|
|
|
194
188
|
/**
|
|
195
189
|
* Ask a question and return the complete response
|
|
196
190
|
*/
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
191
|
+
const askImpl = async ({
|
|
192
|
+
collection,
|
|
193
|
+
question
|
|
194
|
+
}: {
|
|
195
|
+
collection: CollectionResult;
|
|
196
|
+
question: string;
|
|
197
|
+
}) => {
|
|
198
|
+
try {
|
|
199
|
+
metricsInfo('agent.ask.start', {
|
|
200
|
+
provider: config.provider,
|
|
201
|
+
model: config.model,
|
|
202
|
+
questionLength: question.length
|
|
203
|
+
});
|
|
205
204
|
|
|
206
|
-
|
|
205
|
+
await ensureProviderConnected();
|
|
207
206
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}),
|
|
220
|
-
catch: (cause) =>
|
|
221
|
-
new AgentError({
|
|
222
|
-
message: getErrorMessage(cause),
|
|
223
|
-
hint:
|
|
224
|
-
getErrorHint(cause) ??
|
|
225
|
-
'This may be a temporary issue. Try running the command again.',
|
|
226
|
-
cause
|
|
227
|
-
})
|
|
207
|
+
let result: Awaited<ReturnType<typeof runAgentLoop>>;
|
|
208
|
+
try {
|
|
209
|
+
result = await runAgentLoop({
|
|
210
|
+
providerId: config.provider,
|
|
211
|
+
modelId: config.model,
|
|
212
|
+
maxSteps: config.maxSteps,
|
|
213
|
+
collectionPath: collection.path,
|
|
214
|
+
vfsId: collection.vfsId,
|
|
215
|
+
agentInstructions: collection.agentInstructions,
|
|
216
|
+
question,
|
|
217
|
+
providerOptions: config.getProviderOptions(config.provider)
|
|
228
218
|
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
throw new AgentError({
|
|
221
|
+
message: getErrorMessage(cause),
|
|
222
|
+
hint:
|
|
223
|
+
getErrorHint(cause) ?? 'This may be a temporary issue. Try running the command again.',
|
|
224
|
+
cause
|
|
235
225
|
});
|
|
226
|
+
}
|
|
236
227
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
228
|
+
metricsInfo('agent.ask.complete', {
|
|
229
|
+
provider: config.provider,
|
|
230
|
+
model: config.model,
|
|
231
|
+
answerLength: result.answer.length,
|
|
232
|
+
eventCount: result.events.length
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
answer: result.answer,
|
|
237
|
+
model: result.model,
|
|
238
|
+
events: result.events
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
metricsError('agent.ask.error', { error: metricsErrorInfo(error) });
|
|
242
|
+
throw error;
|
|
243
|
+
} finally {
|
|
244
|
+
await cleanupCollection(collection);
|
|
245
|
+
}
|
|
249
246
|
};
|
|
250
247
|
|
|
251
248
|
/**
|
|
252
249
|
* List available providers using local auth data
|
|
253
250
|
*/
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
models: {} as Record<string, unknown>
|
|
262
|
-
}));
|
|
251
|
+
const listProvidersImpl = async () => {
|
|
252
|
+
const supportedProviders = getSupportedProviders();
|
|
253
|
+
const authenticatedProviders = await getAuthenticatedProviders();
|
|
254
|
+
const all = supportedProviders.map((id) => ({
|
|
255
|
+
id,
|
|
256
|
+
models: {} as Record<string, unknown>
|
|
257
|
+
}));
|
|
263
258
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
})
|
|
269
|
-
);
|
|
259
|
+
return {
|
|
260
|
+
all,
|
|
261
|
+
connected: authenticatedProviders
|
|
262
|
+
};
|
|
270
263
|
};
|
|
271
264
|
|
|
272
|
-
const
|
|
265
|
+
const askStream: AgentService['askStream'] = (args) =>
|
|
273
266
|
Effect.tryPromise({
|
|
274
|
-
try: () =>
|
|
267
|
+
try: () => askStreamImpl(args),
|
|
275
268
|
catch: (cause) => cause
|
|
276
269
|
});
|
|
277
|
-
const
|
|
270
|
+
const ask: AgentService['ask'] = (args) =>
|
|
278
271
|
Effect.tryPromise({
|
|
279
|
-
try: () =>
|
|
272
|
+
try: () => askImpl(args),
|
|
280
273
|
catch: (cause) => cause
|
|
281
274
|
});
|
|
282
|
-
const
|
|
275
|
+
const listProviders: AgentService['listProviders'] = () =>
|
|
283
276
|
Effect.tryPromise({
|
|
284
|
-
try: () =>
|
|
277
|
+
try: () => listProvidersImpl(),
|
|
285
278
|
catch: (cause) => cause
|
|
286
279
|
});
|
|
287
280
|
|
|
288
281
|
return {
|
|
289
282
|
askStream,
|
|
290
283
|
ask,
|
|
291
|
-
listProviders
|
|
292
|
-
askStreamEffect,
|
|
293
|
-
askEffect,
|
|
294
|
-
listProvidersEffect
|
|
284
|
+
listProviders
|
|
295
285
|
};
|
|
296
286
|
};
|