@ottocode/server 0.1.204 → 0.1.206
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 +3 -3
- package/src/openapi/paths/git.ts +135 -0
- package/src/openapi/schemas.ts +7 -0
- package/src/routes/files.ts +41 -0
- package/src/routes/git/index.ts +2 -0
- package/src/routes/git/remote.ts +121 -0
- package/src/routes/git/schemas.ts +11 -0
- package/src/routes/git/status.ts +22 -0
- package/src/routes/mcp.ts +4 -0
- package/src/routes/setu.ts +35 -10
- package/src/runtime/agent/mcp-prepare-step.ts +69 -0
- package/src/runtime/agent/runner-setup.ts +14 -5
- package/src/runtime/agent/runner.ts +48 -1
- package/src/runtime/message/tool-history-tracker.ts +54 -0
- package/src/runtime/provider/index.ts +2 -0
- package/src/runtime/provider/setu.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.206",
|
|
4
4
|
"description": "HTTP API server for ottocode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"typecheck": "tsc --noEmit"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@ottocode/sdk": "0.1.
|
|
53
|
-
"@ottocode/database": "0.1.
|
|
52
|
+
"@ottocode/sdk": "0.1.206",
|
|
53
|
+
"@ottocode/database": "0.1.206",
|
|
54
54
|
"drizzle-orm": "^0.44.5",
|
|
55
55
|
"hono": "^4.9.9",
|
|
56
56
|
"zod": "^4.1.8"
|
package/src/openapi/paths/git.ts
CHANGED
|
@@ -500,4 +500,139 @@ export const gitPaths = {
|
|
|
500
500
|
},
|
|
501
501
|
},
|
|
502
502
|
},
|
|
503
|
+
'/v1/git/remotes': {
|
|
504
|
+
get: {
|
|
505
|
+
tags: ['git'],
|
|
506
|
+
operationId: 'getGitRemotes',
|
|
507
|
+
summary: 'List git remotes',
|
|
508
|
+
parameters: [projectQueryParam()],
|
|
509
|
+
responses: {
|
|
510
|
+
200: {
|
|
511
|
+
description: 'OK',
|
|
512
|
+
content: {
|
|
513
|
+
'application/json': {
|
|
514
|
+
schema: {
|
|
515
|
+
type: 'object',
|
|
516
|
+
properties: {
|
|
517
|
+
status: { type: 'string', enum: ['ok'] },
|
|
518
|
+
data: {
|
|
519
|
+
type: 'object',
|
|
520
|
+
properties: {
|
|
521
|
+
remotes: {
|
|
522
|
+
type: 'array',
|
|
523
|
+
items: {
|
|
524
|
+
type: 'object',
|
|
525
|
+
properties: {
|
|
526
|
+
name: { type: 'string' },
|
|
527
|
+
url: { type: 'string' },
|
|
528
|
+
type: { type: 'string' },
|
|
529
|
+
},
|
|
530
|
+
required: ['name', 'url', 'type'],
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
required: ['remotes'],
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
required: ['status', 'data'],
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
400: gitErrorResponse(),
|
|
543
|
+
500: gitErrorResponse(),
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
post: {
|
|
547
|
+
tags: ['git'],
|
|
548
|
+
operationId: 'addGitRemote',
|
|
549
|
+
summary: 'Add a git remote',
|
|
550
|
+
requestBody: {
|
|
551
|
+
required: true,
|
|
552
|
+
content: {
|
|
553
|
+
'application/json': {
|
|
554
|
+
schema: {
|
|
555
|
+
type: 'object',
|
|
556
|
+
properties: {
|
|
557
|
+
project: { type: 'string' },
|
|
558
|
+
name: { type: 'string' },
|
|
559
|
+
url: { type: 'string' },
|
|
560
|
+
},
|
|
561
|
+
required: ['name', 'url'],
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
responses: {
|
|
567
|
+
200: {
|
|
568
|
+
description: 'OK',
|
|
569
|
+
content: {
|
|
570
|
+
'application/json': {
|
|
571
|
+
schema: {
|
|
572
|
+
type: 'object',
|
|
573
|
+
properties: {
|
|
574
|
+
status: { type: 'string', enum: ['ok'] },
|
|
575
|
+
data: {
|
|
576
|
+
type: 'object',
|
|
577
|
+
properties: {
|
|
578
|
+
name: { type: 'string' },
|
|
579
|
+
url: { type: 'string' },
|
|
580
|
+
},
|
|
581
|
+
required: ['name', 'url'],
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
required: ['status', 'data'],
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
400: gitErrorResponse(),
|
|
590
|
+
500: gitErrorResponse(),
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
delete: {
|
|
594
|
+
tags: ['git'],
|
|
595
|
+
operationId: 'removeGitRemote',
|
|
596
|
+
summary: 'Remove a git remote',
|
|
597
|
+
requestBody: {
|
|
598
|
+
required: true,
|
|
599
|
+
content: {
|
|
600
|
+
'application/json': {
|
|
601
|
+
schema: {
|
|
602
|
+
type: 'object',
|
|
603
|
+
properties: {
|
|
604
|
+
project: { type: 'string' },
|
|
605
|
+
name: { type: 'string' },
|
|
606
|
+
},
|
|
607
|
+
required: ['name'],
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
responses: {
|
|
613
|
+
200: {
|
|
614
|
+
description: 'OK',
|
|
615
|
+
content: {
|
|
616
|
+
'application/json': {
|
|
617
|
+
schema: {
|
|
618
|
+
type: 'object',
|
|
619
|
+
properties: {
|
|
620
|
+
status: { type: 'string', enum: ['ok'] },
|
|
621
|
+
data: {
|
|
622
|
+
type: 'object',
|
|
623
|
+
properties: {
|
|
624
|
+
removed: { type: 'string' },
|
|
625
|
+
},
|
|
626
|
+
required: ['removed'],
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
required: ['status', 'data'],
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
500: gitErrorResponse(),
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
},
|
|
503
638
|
} as const;
|
package/src/openapi/schemas.ts
CHANGED
|
@@ -234,6 +234,11 @@ export const schemas = {
|
|
|
234
234
|
},
|
|
235
235
|
hasChanges: { type: 'boolean' },
|
|
236
236
|
hasConflicts: { type: 'boolean' },
|
|
237
|
+
hasUpstream: { type: 'boolean' },
|
|
238
|
+
remotes: {
|
|
239
|
+
type: 'array',
|
|
240
|
+
items: { type: 'string' },
|
|
241
|
+
},
|
|
237
242
|
},
|
|
238
243
|
required: [
|
|
239
244
|
'branch',
|
|
@@ -245,6 +250,8 @@ export const schemas = {
|
|
|
245
250
|
'conflicted',
|
|
246
251
|
'hasChanges',
|
|
247
252
|
'hasConflicts',
|
|
253
|
+
'hasUpstream',
|
|
254
|
+
'remotes',
|
|
248
255
|
],
|
|
249
256
|
},
|
|
250
257
|
GitFile: {
|
package/src/routes/files.ts
CHANGED
|
@@ -346,4 +346,45 @@ export function registerFilesRoutes(app: Hono) {
|
|
|
346
346
|
return c.json({ error: serializeError(err) }, 500);
|
|
347
347
|
}
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
app.get('/v1/files/raw', async (c) => {
|
|
351
|
+
try {
|
|
352
|
+
const projectRoot = c.req.query('project') || process.cwd();
|
|
353
|
+
const filePath = c.req.query('path');
|
|
354
|
+
|
|
355
|
+
if (!filePath) {
|
|
356
|
+
return c.json({ error: 'Missing required query parameter: path' }, 400);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const absPath = join(projectRoot, filePath);
|
|
360
|
+
if (!absPath.startsWith(projectRoot)) {
|
|
361
|
+
return c.json({ error: 'Path traversal not allowed' }, 403);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
365
|
+
const mimeTypes: Record<string, string> = {
|
|
366
|
+
png: 'image/png',
|
|
367
|
+
jpg: 'image/jpeg',
|
|
368
|
+
jpeg: 'image/jpeg',
|
|
369
|
+
gif: 'image/gif',
|
|
370
|
+
svg: 'image/svg+xml',
|
|
371
|
+
webp: 'image/webp',
|
|
372
|
+
ico: 'image/x-icon',
|
|
373
|
+
bmp: 'image/bmp',
|
|
374
|
+
avif: 'image/avif',
|
|
375
|
+
};
|
|
376
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
377
|
+
|
|
378
|
+
const data = await readFile(absPath);
|
|
379
|
+
return new Response(data, {
|
|
380
|
+
headers: {
|
|
381
|
+
'Content-Type': contentType,
|
|
382
|
+
'Cache-Control': 'no-cache',
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
} catch (err) {
|
|
386
|
+
logger.error('Files raw route error:', err);
|
|
387
|
+
return c.json({ error: serializeError(err) }, 500);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
349
390
|
}
|
package/src/routes/git/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { registerCommitRoutes } from './commit.ts';
|
|
|
7
7
|
import { registerPushRoute } from './push.ts';
|
|
8
8
|
import { registerPullRoute } from './pull.ts';
|
|
9
9
|
import { registerInitRoute } from './init.ts';
|
|
10
|
+
import { registerRemoteRoutes } from './remote.ts';
|
|
10
11
|
|
|
11
12
|
export type { GitFile } from './types.ts';
|
|
12
13
|
|
|
@@ -19,4 +20,5 @@ export function registerGitRoutes(app: Hono) {
|
|
|
19
20
|
registerPushRoute(app);
|
|
20
21
|
registerPullRoute(app);
|
|
21
22
|
registerInitRoute(app);
|
|
23
|
+
registerRemoteRoutes(app);
|
|
22
24
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { gitRemoteAddSchema, gitRemoteRemoveSchema } from './schemas.ts';
|
|
5
|
+
import { validateAndGetGitRoot } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export function registerRemoteRoutes(app: Hono) {
|
|
10
|
+
app.get('/v1/git/remotes', async (c) => {
|
|
11
|
+
try {
|
|
12
|
+
const project = c.req.query('project');
|
|
13
|
+
const requestedPath = project || process.cwd();
|
|
14
|
+
|
|
15
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
16
|
+
if ('error' in validation) {
|
|
17
|
+
return c.json(
|
|
18
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
19
|
+
400,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { gitRoot } = validation;
|
|
24
|
+
|
|
25
|
+
const { stdout } = await execFileAsync('git', ['remote', '-v'], {
|
|
26
|
+
cwd: gitRoot,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const remotes: { name: string; url: string; type: string }[] = [];
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
|
32
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
|
|
33
|
+
if (match) {
|
|
34
|
+
const key = `${match[1]}:${match[3]}`;
|
|
35
|
+
if (!seen.has(key)) {
|
|
36
|
+
seen.add(key);
|
|
37
|
+
remotes.push({
|
|
38
|
+
name: match[1],
|
|
39
|
+
url: match[2],
|
|
40
|
+
type: match[3],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return c.json({ status: 'ok', data: { remotes } });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return c.json(
|
|
49
|
+
{
|
|
50
|
+
status: 'error',
|
|
51
|
+
error:
|
|
52
|
+
error instanceof Error ? error.message : 'Failed to list remotes',
|
|
53
|
+
},
|
|
54
|
+
500,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.post('/v1/git/remotes', async (c) => {
|
|
60
|
+
try {
|
|
61
|
+
const body = await c.req.json().catch(() => ({}));
|
|
62
|
+
const { project, name, url } = gitRemoteAddSchema.parse(body);
|
|
63
|
+
const requestedPath = project || process.cwd();
|
|
64
|
+
|
|
65
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
66
|
+
if ('error' in validation) {
|
|
67
|
+
return c.json(
|
|
68
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
69
|
+
400,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { gitRoot } = validation;
|
|
74
|
+
|
|
75
|
+
await execFileAsync('git', ['remote', 'add', name, url], {
|
|
76
|
+
cwd: gitRoot,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return c.json({
|
|
80
|
+
status: 'ok',
|
|
81
|
+
data: { name, url },
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message =
|
|
85
|
+
error instanceof Error ? error.message : 'Failed to add remote';
|
|
86
|
+
const status = message.includes('already exists') ? 400 : 500;
|
|
87
|
+
return c.json({ status: 'error', error: message }, status);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.delete('/v1/git/remotes', async (c) => {
|
|
92
|
+
try {
|
|
93
|
+
const body = await c.req.json().catch(() => ({}));
|
|
94
|
+
const { project, name } = gitRemoteRemoveSchema.parse(body);
|
|
95
|
+
const requestedPath = project || process.cwd();
|
|
96
|
+
|
|
97
|
+
const validation = await validateAndGetGitRoot(requestedPath);
|
|
98
|
+
if ('error' in validation) {
|
|
99
|
+
return c.json(
|
|
100
|
+
{ status: 'error', error: validation.error, code: validation.code },
|
|
101
|
+
400,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { gitRoot } = validation;
|
|
106
|
+
|
|
107
|
+
await execFileAsync('git', ['remote', 'remove', name], {
|
|
108
|
+
cwd: gitRoot,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return c.json({
|
|
112
|
+
status: 'ok',
|
|
113
|
+
data: { removed: name },
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message =
|
|
117
|
+
error instanceof Error ? error.message : 'Failed to remove remote';
|
|
118
|
+
return c.json({ status: 'error', error: message }, 500);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -50,3 +50,14 @@ export const gitPushSchema = z.object({
|
|
|
50
50
|
export const gitPullSchema = z.object({
|
|
51
51
|
project: z.string().optional(),
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
export const gitRemoteAddSchema = z.object({
|
|
55
|
+
project: z.string().optional(),
|
|
56
|
+
name: z.string().min(1),
|
|
57
|
+
url: z.string().min(1),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const gitRemoteRemoveSchema = z.object({
|
|
61
|
+
project: z.string().optional(),
|
|
62
|
+
name: z.string().min(1),
|
|
63
|
+
});
|
package/src/routes/git/status.ts
CHANGED
|
@@ -45,6 +45,26 @@ export function registerStatusRoute(app: Hono) {
|
|
|
45
45
|
|
|
46
46
|
const branch = await getCurrentBranch(gitRoot);
|
|
47
47
|
|
|
48
|
+
let hasUpstream = false;
|
|
49
|
+
try {
|
|
50
|
+
await execFileAsync(
|
|
51
|
+
'git',
|
|
52
|
+
['rev-parse', '--abbrev-ref', '@{upstream}'],
|
|
53
|
+
{ cwd: gitRoot },
|
|
54
|
+
);
|
|
55
|
+
hasUpstream = true;
|
|
56
|
+
} catch {}
|
|
57
|
+
|
|
58
|
+
let remotes: string[] = [];
|
|
59
|
+
try {
|
|
60
|
+
const { stdout: remotesOutput } = await execFileAsync(
|
|
61
|
+
'git',
|
|
62
|
+
['remote'],
|
|
63
|
+
{ cwd: gitRoot },
|
|
64
|
+
);
|
|
65
|
+
remotes = remotesOutput.trim().split('\n').filter(Boolean);
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
48
68
|
const hasChanges =
|
|
49
69
|
staged.length > 0 ||
|
|
50
70
|
unstaged.length > 0 ||
|
|
@@ -59,6 +79,8 @@ export function registerStatusRoute(app: Hono) {
|
|
|
59
79
|
branch,
|
|
60
80
|
ahead,
|
|
61
81
|
behind,
|
|
82
|
+
hasUpstream,
|
|
83
|
+
remotes,
|
|
62
84
|
gitRoot,
|
|
63
85
|
workingDir: requestedPath,
|
|
64
86
|
staged,
|
package/src/routes/mcp.ts
CHANGED
|
@@ -104,6 +104,10 @@ export function registerMCPRoutes(app: Hono) {
|
|
|
104
104
|
try {
|
|
105
105
|
const manager = getMCPManager();
|
|
106
106
|
if (manager) {
|
|
107
|
+
const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
|
|
108
|
+
const serverConfig = config.servers.find((s) => s.name === name);
|
|
109
|
+
const scope = serverConfig?.scope ?? 'global';
|
|
110
|
+
await manager.clearAuthData(name, scope, projectRoot);
|
|
107
111
|
await manager.stopServer(name);
|
|
108
112
|
}
|
|
109
113
|
|
package/src/routes/setu.ts
CHANGED
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
getPublicKeyFromPrivate,
|
|
5
5
|
getAuth,
|
|
6
6
|
loadConfig,
|
|
7
|
-
fetchSolanaUsdcBalance,
|
|
8
7
|
} from '@ottocode/sdk';
|
|
9
8
|
import { logger } from '@ottocode/sdk';
|
|
10
9
|
import { serializeError } from '../runtime/errors/api-error.ts';
|
|
@@ -117,18 +116,44 @@ export function registerSetuRoutes(app: Hono) {
|
|
|
117
116
|
return c.json({ error: 'Setu wallet not configured' }, 401);
|
|
118
117
|
}
|
|
119
118
|
|
|
120
|
-
const
|
|
121
|
-
|
|
119
|
+
const publicKey = getPublicKeyFromPrivate(privateKey);
|
|
120
|
+
if (!publicKey) {
|
|
121
|
+
return c.json({ error: 'Invalid private key' }, 400);
|
|
122
|
+
}
|
|
122
123
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const baseUrl = getSetuBaseUrl();
|
|
125
|
+
const response = await fetch(
|
|
126
|
+
`${baseUrl}/v1/wallet/${publicKey}/balances?limit=100&showNative=false&showNfts=false&showZeroBalance=false`,
|
|
127
|
+
{
|
|
128
|
+
method: 'GET',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
return c.json({ error: 'Failed to fetch wallet balances' }, 502);
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
|
|
137
|
+
const data = (await response.json()) as {
|
|
138
|
+
balances: Array<{
|
|
139
|
+
mint: string;
|
|
140
|
+
symbol: string;
|
|
141
|
+
name: string;
|
|
142
|
+
balance: number;
|
|
143
|
+
decimals: number;
|
|
144
|
+
pricePerToken: number | null;
|
|
145
|
+
usdValue: number | null;
|
|
146
|
+
}>;
|
|
147
|
+
totalUsdValue: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const usdcEntry = data.balances.find((b) => b.symbol === 'USDC');
|
|
151
|
+
|
|
152
|
+
return c.json({
|
|
153
|
+
walletAddress: publicKey,
|
|
154
|
+
usdcBalance: usdcEntry?.balance ?? 0,
|
|
155
|
+
network: 'mainnet' as const,
|
|
156
|
+
});
|
|
132
157
|
} catch (error) {
|
|
133
158
|
logger.error('Failed to fetch USDC balance', error);
|
|
134
159
|
const errorResponse = serializeError(error);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Tool } from 'ai';
|
|
2
|
+
import { debugLog } from '../debug/index.ts';
|
|
3
|
+
|
|
4
|
+
export interface MCPPrepareStepState {
|
|
5
|
+
mcpToolsRecord: Record<string, Tool>;
|
|
6
|
+
loadedMCPTools: Set<string>;
|
|
7
|
+
baseToolNames: string[];
|
|
8
|
+
canonicalToRegistration: Record<string, string>;
|
|
9
|
+
loadToolRegistrationName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createMCPPrepareStepState(
|
|
13
|
+
mcpToolsRecord: Record<string, Tool>,
|
|
14
|
+
baseToolNames: string[],
|
|
15
|
+
canonicalToRegistration: Record<string, string>,
|
|
16
|
+
loadToolRegistrationName: string,
|
|
17
|
+
): MCPPrepareStepState {
|
|
18
|
+
return {
|
|
19
|
+
mcpToolsRecord,
|
|
20
|
+
loadedMCPTools: new Set(),
|
|
21
|
+
baseToolNames,
|
|
22
|
+
canonicalToRegistration,
|
|
23
|
+
loadToolRegistrationName,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildPrepareStep(state: MCPPrepareStepState) {
|
|
28
|
+
return async ({
|
|
29
|
+
stepNumber,
|
|
30
|
+
steps,
|
|
31
|
+
}: {
|
|
32
|
+
stepNumber: number;
|
|
33
|
+
steps: unknown[];
|
|
34
|
+
}) => {
|
|
35
|
+
const previousSteps = steps as Array<{
|
|
36
|
+
toolCalls?: Array<{ toolName: string; input: unknown }>;
|
|
37
|
+
toolResults?: Array<{ toolName: string; output: unknown }>;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
for (const step of previousSteps) {
|
|
41
|
+
if (!step.toolCalls) continue;
|
|
42
|
+
for (const call of step.toolCalls) {
|
|
43
|
+
if (call.toolName !== state.loadToolRegistrationName) continue;
|
|
44
|
+
const result = (step.toolResults ?? []).find(
|
|
45
|
+
(r) => r.toolName === state.loadToolRegistrationName,
|
|
46
|
+
);
|
|
47
|
+
const output = result?.output as { loaded?: string[] } | undefined;
|
|
48
|
+
if (!output?.loaded) continue;
|
|
49
|
+
for (const canonicalName of output.loaded) {
|
|
50
|
+
const regName =
|
|
51
|
+
state.canonicalToRegistration[canonicalName] ?? canonicalName;
|
|
52
|
+
if (!state.loadedMCPTools.has(regName)) {
|
|
53
|
+
state.loadedMCPTools.add(regName);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const activeTools = [...state.baseToolNames, ...state.loadedMCPTools];
|
|
60
|
+
|
|
61
|
+
if (state.loadedMCPTools.size > 0) {
|
|
62
|
+
debugLog(
|
|
63
|
+
`[MCP prepareStep] step=${stepNumber}, active MCP tools: ${[...state.loadedMCPTools].join(', ')}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { activeTools };
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -8,6 +8,7 @@ import { resolveModel } from '../provider/index.ts';
|
|
|
8
8
|
import { resolveAgentConfig } from './registry.ts';
|
|
9
9
|
import { composeSystemPrompt } from '../prompt/builder.ts';
|
|
10
10
|
import { discoverProjectTools } from '@ottocode/sdk';
|
|
11
|
+
import type { Tool } from 'ai';
|
|
11
12
|
import { adaptTools } from '../../tools/adapter.ts';
|
|
12
13
|
import { buildDatabaseTools } from '../../tools/database/index.ts';
|
|
13
14
|
import { debugLog, time, isDebugEnabled } from '../debug/index.ts';
|
|
@@ -39,6 +40,7 @@ export interface SetupResult {
|
|
|
39
40
|
providerOptions: Record<string, unknown>;
|
|
40
41
|
needsSpoof: boolean;
|
|
41
42
|
isOpenAIOAuth: boolean;
|
|
43
|
+
mcpToolsRecord: Record<string, Tool>;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const THINKING_BUDGET = 16000;
|
|
@@ -143,7 +145,9 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
|
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
const toolsTimer = time('runner:discoverTools');
|
|
146
|
-
const
|
|
148
|
+
const discovered = await discoverProjectTools(cfg.projectRoot);
|
|
149
|
+
const allTools = discovered.tools;
|
|
150
|
+
const { mcpToolsRecord } = discovered;
|
|
147
151
|
|
|
148
152
|
if (opts.agent === 'research') {
|
|
149
153
|
const currentSession = sessionRows[0];
|
|
@@ -151,19 +155,23 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
|
|
|
151
155
|
|
|
152
156
|
const dbTools = buildDatabaseTools(cfg.projectRoot, parentSessionId);
|
|
153
157
|
for (const dt of dbTools) {
|
|
154
|
-
|
|
158
|
+
discovered.tools.push(dt);
|
|
155
159
|
}
|
|
156
160
|
debugLog(
|
|
157
161
|
`[tools] Added ${dbTools.length} database tools for research agent (parent: ${parentSessionId ?? 'none'})`,
|
|
158
162
|
);
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
toolsTimer.end({
|
|
165
|
+
toolsTimer.end({
|
|
166
|
+
count: allTools.length + Object.keys(mcpToolsRecord).length,
|
|
167
|
+
});
|
|
162
168
|
const allowedNames = new Set([...(agentCfg.tools || []), 'finish']);
|
|
163
169
|
const gated = allTools.filter(
|
|
164
|
-
(tool) => allowedNames.has(tool.name) || tool.name
|
|
170
|
+
(tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
|
|
171
|
+
);
|
|
172
|
+
debugLog(
|
|
173
|
+
`[tools] ${gated.length} gated tools, ${Object.keys(mcpToolsRecord).length} lazy MCP tools`,
|
|
165
174
|
);
|
|
166
|
-
debugLog(`[tools] ${gated.length} allowed tools (including MCP)`);
|
|
167
175
|
|
|
168
176
|
debugLog(`[RUNNER] About to create model with provider: ${opts.provider}`);
|
|
169
177
|
debugLog(`[RUNNER] About to create model ID: ${opts.model}`);
|
|
@@ -249,6 +257,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
|
|
|
249
257
|
providerOptions,
|
|
250
258
|
needsSpoof: oauth.needsSpoof,
|
|
251
259
|
isOpenAIOAuth: oauth.isOpenAIOAuth,
|
|
260
|
+
mcpToolsRecord,
|
|
252
261
|
};
|
|
253
262
|
}
|
|
254
263
|
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
import { pruneSession } from '../message/compaction.ts';
|
|
27
27
|
import { triggerDeferredTitleGeneration } from '../message/service.ts';
|
|
28
28
|
import { setupRunner } from './runner-setup.ts';
|
|
29
|
+
import {
|
|
30
|
+
createMCPPrepareStepState,
|
|
31
|
+
buildPrepareStep,
|
|
32
|
+
} from './mcp-prepare-step.ts';
|
|
33
|
+
import { adaptTools as adaptToolsFn } from '../../tools/adapter.ts';
|
|
29
34
|
import {
|
|
30
35
|
type ReasoningState,
|
|
31
36
|
handleReasoningStart,
|
|
@@ -83,13 +88,54 @@ async function runAssistant(opts: RunOpts) {
|
|
|
83
88
|
additionalSystemMessages,
|
|
84
89
|
model,
|
|
85
90
|
effectiveMaxOutputTokens,
|
|
86
|
-
toolset,
|
|
87
91
|
sharedCtx,
|
|
88
92
|
firstToolTimer,
|
|
89
93
|
firstToolSeen,
|
|
90
94
|
providerOptions,
|
|
91
95
|
isOpenAIOAuth,
|
|
96
|
+
mcpToolsRecord,
|
|
92
97
|
} = setup;
|
|
98
|
+
let { toolset } = setup;
|
|
99
|
+
|
|
100
|
+
const hasMCPTools = Object.keys(mcpToolsRecord).length > 0;
|
|
101
|
+
let prepareStep: ReturnType<typeof buildPrepareStep> | undefined;
|
|
102
|
+
|
|
103
|
+
if (hasMCPTools) {
|
|
104
|
+
const baseToolNames = Object.keys(toolset);
|
|
105
|
+
const { getAuth: getAuthFn } = await import('@ottocode/sdk');
|
|
106
|
+
const providerAuth = await getAuthFn(opts.provider, cfg.projectRoot);
|
|
107
|
+
const adaptedMCP = adaptToolsFn(
|
|
108
|
+
Object.entries(mcpToolsRecord).map(([name, tool]) => ({ name, tool })),
|
|
109
|
+
sharedCtx,
|
|
110
|
+
opts.provider,
|
|
111
|
+
providerAuth?.type,
|
|
112
|
+
);
|
|
113
|
+
toolset = { ...toolset, ...adaptedMCP };
|
|
114
|
+
const canonicalToRegistration: Record<string, string> = {};
|
|
115
|
+
for (const canonical of Object.keys(mcpToolsRecord)) {
|
|
116
|
+
const regKeys = Object.keys(adaptedMCP);
|
|
117
|
+
const regName = regKeys.find(
|
|
118
|
+
(k) =>
|
|
119
|
+
k === canonical ||
|
|
120
|
+
k.toLowerCase().replace(/_/g, '') ===
|
|
121
|
+
canonical.toLowerCase().replace(/_/g, ''),
|
|
122
|
+
);
|
|
123
|
+
canonicalToRegistration[canonical] = regName ?? canonical;
|
|
124
|
+
}
|
|
125
|
+
const loadToolRegName =
|
|
126
|
+
Object.keys(toolset).find(
|
|
127
|
+
(k) =>
|
|
128
|
+
k === 'load_mcp_tools' ||
|
|
129
|
+
k.toLowerCase().replace(/_/g, '') === 'loadmcptools',
|
|
130
|
+
) ?? 'load_mcp_tools';
|
|
131
|
+
const mcpState = createMCPPrepareStepState(
|
|
132
|
+
mcpToolsRecord,
|
|
133
|
+
baseToolNames,
|
|
134
|
+
canonicalToRegistration,
|
|
135
|
+
loadToolRegName,
|
|
136
|
+
);
|
|
137
|
+
prepareStep = buildPrepareStep(mcpState);
|
|
138
|
+
}
|
|
93
139
|
|
|
94
140
|
const isFirstMessage = !history.some((m) => m.role === 'assistant');
|
|
95
141
|
|
|
@@ -213,6 +259,7 @@ async function runAssistant(opts: RunOpts) {
|
|
|
213
259
|
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
|
|
214
260
|
abortSignal: opts.abortSignal,
|
|
215
261
|
stopWhen: stopWhenCondition,
|
|
262
|
+
...(prepareStep ? { prepareStep } : {}),
|
|
216
263
|
// biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
|
|
217
264
|
onStepFinish: onStepFinish as any,
|
|
218
265
|
// biome-ignore lint/suspicious/noExplicitAny: AI SDK callback types mismatch
|
|
@@ -80,6 +80,9 @@ function describeToolResult(info: ToolResultInfo): TargetDescriptor | null {
|
|
|
80
80
|
case 'multiedit':
|
|
81
81
|
return describeEdit(info);
|
|
82
82
|
default:
|
|
83
|
+
if (toolName.includes('__')) {
|
|
84
|
+
return describeMcpTool(info);
|
|
85
|
+
}
|
|
83
86
|
return null;
|
|
84
87
|
}
|
|
85
88
|
}
|
|
@@ -215,3 +218,54 @@ function describeEdit(info: ToolResultInfo): TargetDescriptor | null {
|
|
|
215
218
|
const summary = `[previous edit] ${normalized}`;
|
|
216
219
|
return { keys: [key], summary };
|
|
217
220
|
}
|
|
221
|
+
|
|
222
|
+
function describeMcpTool(info: ToolResultInfo): TargetDescriptor | null {
|
|
223
|
+
const { toolName } = info;
|
|
224
|
+
const result = getRecord(info.result);
|
|
225
|
+
const args = getRecord(info.args);
|
|
226
|
+
|
|
227
|
+
const hasImages =
|
|
228
|
+
result && Array.isArray(result.images) && result.images.length > 0;
|
|
229
|
+
const resultStr =
|
|
230
|
+
result && typeof result.result === 'string' ? result.result : null;
|
|
231
|
+
const estimatedSize = hasImages
|
|
232
|
+
? estimateBase64Size(result.images as Array<{ data: string }>)
|
|
233
|
+
: resultStr
|
|
234
|
+
? resultStr.length
|
|
235
|
+
: 0;
|
|
236
|
+
|
|
237
|
+
if (estimatedSize < 2000 && !hasImages) return null;
|
|
238
|
+
|
|
239
|
+
const argsHint = args
|
|
240
|
+
? Object.entries(args)
|
|
241
|
+
.slice(0, 3)
|
|
242
|
+
.map(([k, v]) => {
|
|
243
|
+
const val =
|
|
244
|
+
typeof v === 'string'
|
|
245
|
+
? v.length > 30
|
|
246
|
+
? `${v.slice(0, 27)}…`
|
|
247
|
+
: v
|
|
248
|
+
: JSON.stringify(v);
|
|
249
|
+
return `${k}=${val}`;
|
|
250
|
+
})
|
|
251
|
+
.join(' ')
|
|
252
|
+
: '';
|
|
253
|
+
|
|
254
|
+
const sizeLabel = hasImages
|
|
255
|
+
? `${(result.images as unknown[]).length} image(s), ~${Math.round(estimatedSize / 1024)}KB`
|
|
256
|
+
: `~${Math.round(estimatedSize / 1024)}KB`;
|
|
257
|
+
|
|
258
|
+
const key = `mcp:${toolName}`;
|
|
259
|
+
const summary = `[previous MCP call] ${toolName}${argsHint ? ` (${argsHint})` : ''} → ${sizeLabel}`;
|
|
260
|
+
return { keys: [key], summary };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function estimateBase64Size(images: Array<{ data: string }>): number {
|
|
264
|
+
let total = 0;
|
|
265
|
+
for (const img of images) {
|
|
266
|
+
if (typeof img.data === 'string') {
|
|
267
|
+
total += Math.floor(img.data.length * 0.75);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return total;
|
|
271
|
+
}
|
|
@@ -21,6 +21,7 @@ export async function resolveModel(
|
|
|
21
21
|
sessionId?: string;
|
|
22
22
|
messageId?: string;
|
|
23
23
|
topupApprovalMode?: ResolveSetuModelOptions['topupApprovalMode'];
|
|
24
|
+
autoPayThresholdUsd?: ResolveSetuModelOptions['autoPayThresholdUsd'];
|
|
24
25
|
},
|
|
25
26
|
) {
|
|
26
27
|
if (provider === 'openai') {
|
|
@@ -46,6 +47,7 @@ export async function resolveModel(
|
|
|
46
47
|
return await resolveSetuModel(model, options?.sessionId, {
|
|
47
48
|
messageId: options?.messageId,
|
|
48
49
|
topupApprovalMode: options?.topupApprovalMode,
|
|
50
|
+
autoPayThresholdUsd: options?.autoPayThresholdUsd,
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
if (provider === 'zai') {
|
|
@@ -21,6 +21,7 @@ function getProviderNpm(model: string): string | undefined {
|
|
|
21
21
|
export interface ResolveSetuModelOptions {
|
|
22
22
|
messageId?: string;
|
|
23
23
|
topupApprovalMode?: 'auto' | 'approval';
|
|
24
|
+
autoPayThresholdUsd?: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
async function getSetuPrivateKey(): Promise<string> {
|
|
@@ -50,7 +51,11 @@ export async function resolveSetuModel(
|
|
|
50
51
|
}
|
|
51
52
|
const baseURL = process.env.SETU_BASE_URL;
|
|
52
53
|
const rpcURL = process.env.SETU_SOLANA_RPC_URL;
|
|
53
|
-
const {
|
|
54
|
+
const {
|
|
55
|
+
messageId,
|
|
56
|
+
topupApprovalMode = 'approval',
|
|
57
|
+
autoPayThresholdUsd = MIN_TOPUP_USD,
|
|
58
|
+
} = options;
|
|
54
59
|
|
|
55
60
|
const callbacks: SetuPaymentCallbacks = sessionId
|
|
56
61
|
? {
|
|
@@ -128,6 +133,7 @@ export async function resolveSetuModel(
|
|
|
128
133
|
callbacks,
|
|
129
134
|
providerNpm,
|
|
130
135
|
topupApprovalMode,
|
|
136
|
+
autoPayThresholdUsd,
|
|
131
137
|
},
|
|
132
138
|
);
|
|
133
139
|
}
|