@agi-cli/server 0.1.55
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 +41 -0
- package/src/events/bus.ts +28 -0
- package/src/events/types.ts +20 -0
- package/src/index.ts +183 -0
- package/src/openapi/spec.ts +474 -0
- package/src/routes/ask.ts +59 -0
- package/src/routes/config.ts +124 -0
- package/src/routes/git.ts +736 -0
- package/src/routes/openapi.ts +6 -0
- package/src/routes/root.ts +5 -0
- package/src/routes/session-messages.ts +123 -0
- package/src/routes/session-stream.ts +45 -0
- package/src/routes/sessions.ts +87 -0
- package/src/runtime/agent-registry.ts +327 -0
- package/src/runtime/ask-service.ts +363 -0
- package/src/runtime/cwd.ts +69 -0
- package/src/runtime/db-operations.ts +94 -0
- package/src/runtime/debug.ts +104 -0
- package/src/runtime/environment.ts +131 -0
- package/src/runtime/error-handling.ts +196 -0
- package/src/runtime/history-builder.ts +156 -0
- package/src/runtime/message-service.ts +392 -0
- package/src/runtime/prompt.ts +79 -0
- package/src/runtime/provider-selection.ts +123 -0
- package/src/runtime/provider.ts +138 -0
- package/src/runtime/runner.ts +313 -0
- package/src/runtime/session-manager.ts +95 -0
- package/src/runtime/session-queue.ts +82 -0
- package/src/runtime/stream-handlers.ts +275 -0
- package/src/runtime/token-utils.ts +35 -0
- package/src/runtime/tool-context-setup.ts +58 -0
- package/src/runtime/tool-context.ts +72 -0
- package/src/tools/adapter.ts +380 -0
- package/src/types/sql-imports.d.ts +5 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { AGIConfig } from '@agi-cli/sdk';
|
|
2
|
+
import type { ProviderId } from '@agi-cli/sdk';
|
|
3
|
+
import { openai, createOpenAI } from '@ai-sdk/openai';
|
|
4
|
+
import { anthropic, createAnthropic } from '@ai-sdk/anthropic';
|
|
5
|
+
import { google } from '@ai-sdk/google';
|
|
6
|
+
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
7
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
8
|
+
import { getAuth } from '@agi-cli/sdk';
|
|
9
|
+
import { refreshToken } from '@agi-cli/sdk';
|
|
10
|
+
import { setAuth } from '@agi-cli/sdk';
|
|
11
|
+
|
|
12
|
+
export type ProviderName = ProviderId;
|
|
13
|
+
|
|
14
|
+
function getOpenRouterInstance() {
|
|
15
|
+
const apiKey = process.env.OPENROUTER_API_KEY ?? '';
|
|
16
|
+
return createOpenRouter({ apiKey });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getAnthropicInstance(cfg: AGIConfig) {
|
|
20
|
+
const auth = await getAuth('anthropic', cfg.projectRoot);
|
|
21
|
+
|
|
22
|
+
if (auth?.type === 'oauth') {
|
|
23
|
+
let currentAuth = auth;
|
|
24
|
+
|
|
25
|
+
if (currentAuth.expires < Date.now()) {
|
|
26
|
+
const tokens = await refreshToken(currentAuth.refresh);
|
|
27
|
+
await setAuth(
|
|
28
|
+
'anthropic',
|
|
29
|
+
{
|
|
30
|
+
type: 'oauth',
|
|
31
|
+
refresh: tokens.refresh,
|
|
32
|
+
access: tokens.access,
|
|
33
|
+
expires: tokens.expires,
|
|
34
|
+
},
|
|
35
|
+
cfg.projectRoot,
|
|
36
|
+
'global',
|
|
37
|
+
);
|
|
38
|
+
currentAuth = {
|
|
39
|
+
type: 'oauth',
|
|
40
|
+
refresh: tokens.refresh,
|
|
41
|
+
access: tokens.access,
|
|
42
|
+
expires: tokens.expires,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const customFetch = async (
|
|
47
|
+
input: string | URL | Request,
|
|
48
|
+
init?: RequestInit,
|
|
49
|
+
) => {
|
|
50
|
+
const initHeaders = init?.headers;
|
|
51
|
+
const headers: Record<string, string> = {};
|
|
52
|
+
|
|
53
|
+
if (initHeaders) {
|
|
54
|
+
if (initHeaders instanceof Headers) {
|
|
55
|
+
initHeaders.forEach((value, key) => {
|
|
56
|
+
if (key.toLowerCase() !== 'x-api-key') {
|
|
57
|
+
headers[key] = value;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
} else if (Array.isArray(initHeaders)) {
|
|
61
|
+
for (const [key, value] of initHeaders) {
|
|
62
|
+
if (
|
|
63
|
+
key &&
|
|
64
|
+
key.toLowerCase() !== 'x-api-key' &&
|
|
65
|
+
typeof value === 'string'
|
|
66
|
+
) {
|
|
67
|
+
headers[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
for (const [key, value] of Object.entries(initHeaders)) {
|
|
72
|
+
if (
|
|
73
|
+
key.toLowerCase() !== 'x-api-key' &&
|
|
74
|
+
typeof value === 'string'
|
|
75
|
+
) {
|
|
76
|
+
headers[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
headers.authorization = `Bearer ${currentAuth.access}`;
|
|
83
|
+
headers['anthropic-beta'] =
|
|
84
|
+
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14';
|
|
85
|
+
|
|
86
|
+
return fetch(input, {
|
|
87
|
+
...init,
|
|
88
|
+
headers,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
return createAnthropic({
|
|
92
|
+
apiKey: '',
|
|
93
|
+
fetch: customFetch as typeof fetch,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return anthropic;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function resolveModel(
|
|
101
|
+
provider: ProviderName,
|
|
102
|
+
model: string,
|
|
103
|
+
cfg: AGIConfig,
|
|
104
|
+
) {
|
|
105
|
+
if (provider === 'openai') return openai(model);
|
|
106
|
+
if (provider === 'anthropic') {
|
|
107
|
+
const instance = await getAnthropicInstance(cfg);
|
|
108
|
+
return instance(model);
|
|
109
|
+
}
|
|
110
|
+
if (provider === 'google') return google(model);
|
|
111
|
+
if (provider === 'openrouter') {
|
|
112
|
+
const openrouter = getOpenRouterInstance();
|
|
113
|
+
return openrouter.chat(model);
|
|
114
|
+
}
|
|
115
|
+
if (provider === 'opencode') {
|
|
116
|
+
const baseURL = 'https://opencode.ai/zen/v1';
|
|
117
|
+
const apiKey = process.env.OPENCODE_API_KEY ?? '';
|
|
118
|
+
|
|
119
|
+
const ocOpenAI = createOpenAI({ apiKey, baseURL });
|
|
120
|
+
const ocAnthropic = createAnthropic({ apiKey, baseURL });
|
|
121
|
+
const ocCompat = createOpenAICompatible({
|
|
122
|
+
name: 'opencode',
|
|
123
|
+
baseURL,
|
|
124
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const id = model.toLowerCase();
|
|
128
|
+
if (id.includes('claude')) return ocAnthropic(model);
|
|
129
|
+
if (
|
|
130
|
+
id.includes('qwen3-coder') ||
|
|
131
|
+
id.includes('grok-code') ||
|
|
132
|
+
id.includes('kimi-k2')
|
|
133
|
+
)
|
|
134
|
+
return ocCompat(model);
|
|
135
|
+
return ocOpenAI(model);
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Unsupported provider: ${provider}`);
|
|
138
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { hasToolCall, streamText } from 'ai';
|
|
2
|
+
import { loadConfig } from '@agi-cli/sdk';
|
|
3
|
+
import { getDb } from '@agi-cli/database';
|
|
4
|
+
import { messageParts } from '@agi-cli/database/schema';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { resolveModel } from './provider.ts';
|
|
7
|
+
import { resolveAgentConfig } from './agent-registry.ts';
|
|
8
|
+
import { composeSystemPrompt } from './prompt.ts';
|
|
9
|
+
import { discoverProjectTools } from '@agi-cli/sdk';
|
|
10
|
+
import { adaptTools } from '../tools/adapter.ts';
|
|
11
|
+
import { publish, subscribe } from '../events/bus.ts';
|
|
12
|
+
import { debugLog, time } from './debug.ts';
|
|
13
|
+
import { buildHistoryMessages } from './history-builder.ts';
|
|
14
|
+
import { toErrorPayload } from './error-handling.ts';
|
|
15
|
+
import { getMaxOutputTokens } from './token-utils.ts';
|
|
16
|
+
import {
|
|
17
|
+
type RunOpts,
|
|
18
|
+
enqueueAssistantRun as enqueueRun,
|
|
19
|
+
abortSession as abortSessionQueue,
|
|
20
|
+
getRunnerState,
|
|
21
|
+
setRunning,
|
|
22
|
+
dequeueJob,
|
|
23
|
+
cleanupSession,
|
|
24
|
+
} from './session-queue.ts';
|
|
25
|
+
import {
|
|
26
|
+
setupToolContext,
|
|
27
|
+
type RunnerToolContext,
|
|
28
|
+
} from './tool-context-setup.ts';
|
|
29
|
+
import {
|
|
30
|
+
updateSessionTokens,
|
|
31
|
+
completeAssistantMessage,
|
|
32
|
+
cleanupEmptyTextParts,
|
|
33
|
+
} from './db-operations.ts';
|
|
34
|
+
import {
|
|
35
|
+
createStepFinishHandler,
|
|
36
|
+
createErrorHandler,
|
|
37
|
+
createAbortHandler,
|
|
38
|
+
createFinishHandler,
|
|
39
|
+
} from './stream-handlers.ts';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Enqueues an assistant run for processing.
|
|
43
|
+
*/
|
|
44
|
+
export function enqueueAssistantRun(opts: Omit<RunOpts, 'abortSignal'>) {
|
|
45
|
+
enqueueRun(opts, processQueue);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Aborts an active session.
|
|
50
|
+
*/
|
|
51
|
+
export function abortSession(sessionId: string) {
|
|
52
|
+
abortSessionQueue(sessionId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Processes the queue of assistant runs for a session.
|
|
57
|
+
*/
|
|
58
|
+
async function processQueue(sessionId: string) {
|
|
59
|
+
const state = getRunnerState(sessionId);
|
|
60
|
+
if (!state) return;
|
|
61
|
+
if (state.running) return;
|
|
62
|
+
setRunning(sessionId, true);
|
|
63
|
+
|
|
64
|
+
while (state.queue.length > 0) {
|
|
65
|
+
const job = dequeueJob(sessionId);
|
|
66
|
+
if (!job) break;
|
|
67
|
+
try {
|
|
68
|
+
await runAssistant(job);
|
|
69
|
+
} catch (_err) {
|
|
70
|
+
// Swallow to keep the loop alive; event published by runner
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setRunning(sessionId, false);
|
|
75
|
+
cleanupSession(sessionId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ensures the finish tool is called if not already observed.
|
|
80
|
+
*/
|
|
81
|
+
async function ensureFinishToolCalled(
|
|
82
|
+
finishObserved: boolean,
|
|
83
|
+
toolset: ReturnType<typeof adaptTools>,
|
|
84
|
+
sharedCtx: RunnerToolContext,
|
|
85
|
+
stepIndex: number,
|
|
86
|
+
) {
|
|
87
|
+
if (finishObserved || !toolset?.finish?.execute) return;
|
|
88
|
+
|
|
89
|
+
const finishInput = {} as const;
|
|
90
|
+
const callOptions = { input: finishInput } as const;
|
|
91
|
+
|
|
92
|
+
sharedCtx.stepIndex = stepIndex;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await toolset.finish.onInputStart?.(callOptions as never);
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await toolset.finish.onInputAvailable?.(callOptions as never);
|
|
100
|
+
} catch {}
|
|
101
|
+
|
|
102
|
+
await toolset.finish.execute(finishInput, {} as never);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Main function to run the assistant for a given request.
|
|
107
|
+
*/
|
|
108
|
+
async function runAssistant(opts: RunOpts) {
|
|
109
|
+
const cfgTimer = time('runner:loadConfig+db');
|
|
110
|
+
const cfg = await loadConfig(opts.projectRoot);
|
|
111
|
+
const db = await getDb(cfg.projectRoot);
|
|
112
|
+
cfgTimer.end();
|
|
113
|
+
|
|
114
|
+
const agentTimer = time('runner:resolveAgentConfig');
|
|
115
|
+
const agentCfg = await resolveAgentConfig(cfg.projectRoot, opts.agent);
|
|
116
|
+
agentTimer.end({ agent: opts.agent });
|
|
117
|
+
|
|
118
|
+
const agentPrompt = agentCfg.prompt || '';
|
|
119
|
+
|
|
120
|
+
const historyTimer = time('runner:buildHistory');
|
|
121
|
+
const history = await buildHistoryMessages(db, opts.sessionId);
|
|
122
|
+
historyTimer.end({ messages: history.length });
|
|
123
|
+
|
|
124
|
+
const isFirstMessage = history.length === 0;
|
|
125
|
+
|
|
126
|
+
const systemTimer = time('runner:composeSystemPrompt');
|
|
127
|
+
const { getAuth } = await import('@agi-cli/sdk');
|
|
128
|
+
const { getProviderSpoofPrompt } = await import('./prompt.ts');
|
|
129
|
+
const auth = await getAuth(opts.provider, cfg.projectRoot);
|
|
130
|
+
const needsSpoof = auth?.type === 'oauth';
|
|
131
|
+
const spoofPrompt = needsSpoof
|
|
132
|
+
? getProviderSpoofPrompt(opts.provider)
|
|
133
|
+
: undefined;
|
|
134
|
+
|
|
135
|
+
let system: string;
|
|
136
|
+
let additionalSystemMessages: Array<{ role: 'system'; content: string }> = [];
|
|
137
|
+
|
|
138
|
+
if (spoofPrompt) {
|
|
139
|
+
system = spoofPrompt;
|
|
140
|
+
const fullPrompt = await composeSystemPrompt({
|
|
141
|
+
provider: opts.provider,
|
|
142
|
+
model: opts.model,
|
|
143
|
+
projectRoot: cfg.projectRoot,
|
|
144
|
+
agentPrompt,
|
|
145
|
+
oneShot: opts.oneShot,
|
|
146
|
+
spoofPrompt: undefined,
|
|
147
|
+
includeProjectTree: isFirstMessage,
|
|
148
|
+
});
|
|
149
|
+
additionalSystemMessages = [{ role: 'system', content: fullPrompt }];
|
|
150
|
+
} else {
|
|
151
|
+
system = await composeSystemPrompt({
|
|
152
|
+
provider: opts.provider,
|
|
153
|
+
model: opts.model,
|
|
154
|
+
projectRoot: cfg.projectRoot,
|
|
155
|
+
agentPrompt,
|
|
156
|
+
oneShot: opts.oneShot,
|
|
157
|
+
spoofPrompt: undefined,
|
|
158
|
+
includeProjectTree: isFirstMessage,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
systemTimer.end();
|
|
162
|
+
debugLog('[system] composed prompt (provider+base+agent):');
|
|
163
|
+
debugLog(system);
|
|
164
|
+
|
|
165
|
+
const toolsTimer = time('runner:discoverTools');
|
|
166
|
+
const allTools = await discoverProjectTools(cfg.projectRoot);
|
|
167
|
+
toolsTimer.end({ count: allTools.length });
|
|
168
|
+
const allowedNames = new Set([
|
|
169
|
+
...(agentCfg.tools || []),
|
|
170
|
+
'finish',
|
|
171
|
+
'progress_update',
|
|
172
|
+
]);
|
|
173
|
+
const gated = allTools.filter((t) => allowedNames.has(t.name));
|
|
174
|
+
const messagesWithSystemInstructions = [
|
|
175
|
+
...(isFirstMessage ? additionalSystemMessages : []),
|
|
176
|
+
...history,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
|
|
180
|
+
opts,
|
|
181
|
+
db,
|
|
182
|
+
);
|
|
183
|
+
const toolset = adaptTools(gated, sharedCtx);
|
|
184
|
+
|
|
185
|
+
const modelTimer = time('runner:resolveModel');
|
|
186
|
+
const model = await resolveModel(opts.provider, opts.model, cfg);
|
|
187
|
+
modelTimer.end();
|
|
188
|
+
|
|
189
|
+
const maxOutputTokens = getMaxOutputTokens(opts.provider, opts.model);
|
|
190
|
+
|
|
191
|
+
let currentPartId = opts.assistantPartId;
|
|
192
|
+
let accumulated = '';
|
|
193
|
+
let stepIndex = 0;
|
|
194
|
+
|
|
195
|
+
let finishObserved = false;
|
|
196
|
+
const unsubscribeFinish = subscribe(opts.sessionId, (evt) => {
|
|
197
|
+
if (evt.type !== 'tool.result') return;
|
|
198
|
+
try {
|
|
199
|
+
const name = (evt.payload as { name?: string } | undefined)?.name;
|
|
200
|
+
if (name === 'finish') finishObserved = true;
|
|
201
|
+
} catch {}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const streamStartTimer = time('runner:first-delta');
|
|
205
|
+
let firstDeltaSeen = false;
|
|
206
|
+
debugLog(`[streamText] Calling with maxOutputTokens: ${maxOutputTokens}`);
|
|
207
|
+
|
|
208
|
+
// State management helpers
|
|
209
|
+
const getCurrentPartId = () => currentPartId;
|
|
210
|
+
const getStepIndex = () => stepIndex;
|
|
211
|
+
const updateCurrentPartId = (id: string) => {
|
|
212
|
+
currentPartId = id;
|
|
213
|
+
};
|
|
214
|
+
const updateAccumulated = (text: string) => {
|
|
215
|
+
accumulated = text;
|
|
216
|
+
};
|
|
217
|
+
const incrementStepIndex = () => {
|
|
218
|
+
stepIndex += 1;
|
|
219
|
+
return stepIndex;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Create stream handlers
|
|
223
|
+
const onStepFinish = createStepFinishHandler(
|
|
224
|
+
opts,
|
|
225
|
+
db,
|
|
226
|
+
getCurrentPartId,
|
|
227
|
+
getStepIndex,
|
|
228
|
+
sharedCtx,
|
|
229
|
+
updateCurrentPartId,
|
|
230
|
+
updateAccumulated,
|
|
231
|
+
incrementStepIndex,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const onError = createErrorHandler(opts, db, getStepIndex, sharedCtx);
|
|
235
|
+
|
|
236
|
+
const onAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
|
|
237
|
+
|
|
238
|
+
const onFinish = createFinishHandler(
|
|
239
|
+
opts,
|
|
240
|
+
db,
|
|
241
|
+
() => ensureFinishToolCalled(finishObserved, toolset, sharedCtx, stepIndex),
|
|
242
|
+
updateSessionTokens,
|
|
243
|
+
completeAssistantMessage,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const result = streamText({
|
|
248
|
+
model,
|
|
249
|
+
tools: toolset,
|
|
250
|
+
...(String(system || '').trim() ? { system } : {}),
|
|
251
|
+
messages: messagesWithSystemInstructions,
|
|
252
|
+
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
|
253
|
+
abortSignal: opts.abortSignal,
|
|
254
|
+
stopWhen: hasToolCall('finish'),
|
|
255
|
+
onStepFinish,
|
|
256
|
+
onError,
|
|
257
|
+
onAbort,
|
|
258
|
+
onFinish,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
for await (const delta of result.textStream) {
|
|
262
|
+
if (!delta) continue;
|
|
263
|
+
if (!firstDeltaSeen) {
|
|
264
|
+
firstDeltaSeen = true;
|
|
265
|
+
streamStartTimer.end();
|
|
266
|
+
}
|
|
267
|
+
accumulated += delta;
|
|
268
|
+
publish({
|
|
269
|
+
type: 'message.part.delta',
|
|
270
|
+
sessionId: opts.sessionId,
|
|
271
|
+
payload: {
|
|
272
|
+
messageId: opts.assistantMessageId,
|
|
273
|
+
partId: currentPartId,
|
|
274
|
+
stepIndex,
|
|
275
|
+
delta,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
await db
|
|
279
|
+
.update(messageParts)
|
|
280
|
+
.set({ content: JSON.stringify({ text: accumulated }) })
|
|
281
|
+
.where(eq(messageParts.id, currentPartId));
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const errorPayload = toErrorPayload(error);
|
|
285
|
+
await db
|
|
286
|
+
.update(messageParts)
|
|
287
|
+
.set({
|
|
288
|
+
content: JSON.stringify({
|
|
289
|
+
text: accumulated,
|
|
290
|
+
error: errorPayload.message,
|
|
291
|
+
}),
|
|
292
|
+
})
|
|
293
|
+
.where(eq(messageParts.messageId, opts.assistantMessageId));
|
|
294
|
+
publish({
|
|
295
|
+
type: 'error',
|
|
296
|
+
sessionId: opts.sessionId,
|
|
297
|
+
payload: {
|
|
298
|
+
messageId: opts.assistantMessageId,
|
|
299
|
+
error: errorPayload.message,
|
|
300
|
+
details: errorPayload.details,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
throw error;
|
|
304
|
+
} finally {
|
|
305
|
+
if (!firstToolSeen()) firstToolTimer.end({ skipped: true });
|
|
306
|
+
try {
|
|
307
|
+
unsubscribeFinish();
|
|
308
|
+
} catch {}
|
|
309
|
+
try {
|
|
310
|
+
await cleanupEmptyTextParts(opts, db);
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm';
|
|
2
|
+
import type { AGIConfig } from '@agi-cli/sdk';
|
|
3
|
+
import type { DB } from '@agi-cli/database';
|
|
4
|
+
import { sessions } from '@agi-cli/database/schema';
|
|
5
|
+
import {
|
|
6
|
+
validateProviderModel,
|
|
7
|
+
isProviderAuthorized,
|
|
8
|
+
ensureProviderEnv,
|
|
9
|
+
type ProviderId,
|
|
10
|
+
} from '@agi-cli/sdk';
|
|
11
|
+
import { publish } from '../events/bus.ts';
|
|
12
|
+
|
|
13
|
+
type SessionRow = typeof sessions.$inferSelect;
|
|
14
|
+
|
|
15
|
+
type CreateSessionInput = {
|
|
16
|
+
db: DB;
|
|
17
|
+
cfg: AGIConfig;
|
|
18
|
+
agent: string;
|
|
19
|
+
provider: ProviderId;
|
|
20
|
+
model: string;
|
|
21
|
+
title?: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function createSession({
|
|
25
|
+
db,
|
|
26
|
+
cfg,
|
|
27
|
+
agent,
|
|
28
|
+
provider,
|
|
29
|
+
model,
|
|
30
|
+
title,
|
|
31
|
+
}: CreateSessionInput): Promise<SessionRow> {
|
|
32
|
+
validateProviderModel(provider, model);
|
|
33
|
+
const authorized = await isProviderAuthorized(cfg, provider);
|
|
34
|
+
if (!authorized) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Provider ${provider} is not configured. Run \`agi auth login\` to add credentials.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
await ensureProviderEnv(cfg, provider);
|
|
40
|
+
const id = crypto.randomUUID();
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const row = {
|
|
43
|
+
id,
|
|
44
|
+
title: title ?? null,
|
|
45
|
+
agent,
|
|
46
|
+
provider,
|
|
47
|
+
model,
|
|
48
|
+
projectPath: cfg.projectRoot,
|
|
49
|
+
createdAt: now,
|
|
50
|
+
lastActiveAt: null,
|
|
51
|
+
totalInputTokens: null,
|
|
52
|
+
totalOutputTokens: null,
|
|
53
|
+
totalToolTimeMs: null,
|
|
54
|
+
toolCountsJson: null,
|
|
55
|
+
};
|
|
56
|
+
await db.insert(sessions).values(row);
|
|
57
|
+
publish({ type: 'session.created', sessionId: id, payload: row });
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type GetSessionInput = {
|
|
62
|
+
db: DB;
|
|
63
|
+
projectPath: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export async function getSessionById({
|
|
68
|
+
db,
|
|
69
|
+
projectPath,
|
|
70
|
+
sessionId,
|
|
71
|
+
}: GetSessionInput): Promise<SessionRow | undefined> {
|
|
72
|
+
const rows = await db
|
|
73
|
+
.select()
|
|
74
|
+
.from(sessions)
|
|
75
|
+
.where(eq(sessions.id, sessionId));
|
|
76
|
+
if (!rows.length) return undefined;
|
|
77
|
+
const row = rows[0];
|
|
78
|
+
if (row.projectPath !== projectPath) return undefined;
|
|
79
|
+
return row;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type GetLastSessionInput = { db: DB; projectPath: string };
|
|
83
|
+
|
|
84
|
+
export async function getLastSession({
|
|
85
|
+
db,
|
|
86
|
+
projectPath,
|
|
87
|
+
}: GetLastSessionInput): Promise<SessionRow | undefined> {
|
|
88
|
+
const rows = await db
|
|
89
|
+
.select()
|
|
90
|
+
.from(sessions)
|
|
91
|
+
.where(eq(sessions.projectPath, projectPath))
|
|
92
|
+
.orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt))
|
|
93
|
+
.limit(1);
|
|
94
|
+
return rows[0];
|
|
95
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ProviderName } from './provider.ts';
|
|
2
|
+
|
|
3
|
+
export type RunOpts = {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
assistantMessageId: string;
|
|
6
|
+
assistantPartId: string;
|
|
7
|
+
agent: string;
|
|
8
|
+
provider: ProviderName;
|
|
9
|
+
model: string;
|
|
10
|
+
projectRoot: string;
|
|
11
|
+
oneShot?: boolean;
|
|
12
|
+
abortSignal?: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RunnerState = { queue: RunOpts[]; running: boolean };
|
|
16
|
+
|
|
17
|
+
// Global state for session queues
|
|
18
|
+
const runners = new Map<string, RunnerState>();
|
|
19
|
+
|
|
20
|
+
// Track active abort controllers per session
|
|
21
|
+
const sessionAbortControllers = new Map<string, AbortController>();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enqueues an assistant run for a given session.
|
|
25
|
+
* Creates an abort controller for the session if one doesn't exist.
|
|
26
|
+
*/
|
|
27
|
+
export function enqueueAssistantRun(
|
|
28
|
+
opts: Omit<RunOpts, 'abortSignal'>,
|
|
29
|
+
processQueueFn: (sessionId: string) => Promise<void>,
|
|
30
|
+
) {
|
|
31
|
+
// Create abort controller for this session
|
|
32
|
+
const abortController = new AbortController();
|
|
33
|
+
sessionAbortControllers.set(opts.sessionId, abortController);
|
|
34
|
+
|
|
35
|
+
const state = runners.get(opts.sessionId) ?? { queue: [], running: false };
|
|
36
|
+
state.queue.push({ ...opts, abortSignal: abortController.signal });
|
|
37
|
+
runners.set(opts.sessionId, state);
|
|
38
|
+
if (!state.running) void processQueueFn(opts.sessionId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Aborts all pending operations for a given session.
|
|
43
|
+
*/
|
|
44
|
+
export function abortSession(sessionId: string) {
|
|
45
|
+
const controller = sessionAbortControllers.get(sessionId);
|
|
46
|
+
if (controller) {
|
|
47
|
+
controller.abort();
|
|
48
|
+
sessionAbortControllers.delete(sessionId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Gets the current state of a session's queue.
|
|
54
|
+
*/
|
|
55
|
+
export function getRunnerState(sessionId: string): RunnerState | undefined {
|
|
56
|
+
return runners.get(sessionId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Marks a session queue as running.
|
|
61
|
+
*/
|
|
62
|
+
export function setRunning(sessionId: string, running: boolean) {
|
|
63
|
+
const state = runners.get(sessionId);
|
|
64
|
+
if (state) {
|
|
65
|
+
state.running = running;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dequeues the next job from a session's queue.
|
|
71
|
+
*/
|
|
72
|
+
export function dequeueJob(sessionId: string): RunOpts | undefined {
|
|
73
|
+
const state = runners.get(sessionId);
|
|
74
|
+
return state?.queue.shift();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cleanup abort controller for a session (called when queue is done).
|
|
79
|
+
*/
|
|
80
|
+
export function cleanupSession(sessionId: string) {
|
|
81
|
+
sessionAbortControllers.delete(sessionId);
|
|
82
|
+
}
|