@ottocode/server 0.1.244 → 0.1.246
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 +4 -3
- package/src/events/types.ts +9 -9
- package/src/index.ts +9 -4
- package/src/openapi/paths/auth.ts +11 -11
- package/src/openapi/paths/config.ts +118 -2
- package/src/openapi/paths/{setu.ts → ottorouter.ts} +31 -31
- package/src/openapi/paths/skills.ts +122 -0
- package/src/openapi/schemas.ts +35 -3
- package/src/openapi/spec.ts +3 -3
- package/src/routes/auth.ts +40 -46
- package/src/routes/branch.ts +3 -2
- package/src/routes/config/defaults.ts +10 -3
- package/src/routes/config/main.ts +3 -0
- package/src/routes/config/models.ts +84 -14
- package/src/routes/config/providers.ts +137 -4
- package/src/routes/config/utils.ts +72 -2
- package/src/routes/doctor.ts +15 -27
- package/src/routes/git/commit.ts +16 -5
- package/src/routes/{setu.ts → ottorouter.ts} +52 -49
- package/src/routes/research.ts +3 -3
- package/src/routes/session-messages.ts +14 -8
- package/src/routes/sessions.ts +12 -18
- package/src/routes/skills.ts +140 -59
- package/src/runtime/agent/registry.ts +5 -2
- package/src/runtime/agent/runner-setup.ts +123 -38
- package/src/runtime/agent/runner.ts +140 -4
- package/src/runtime/ask/service.ts +14 -11
- package/src/runtime/message/history-builder.ts +22 -6
- package/src/runtime/message/service.ts +7 -1
- package/src/runtime/prompt/builder.ts +12 -0
- package/src/runtime/prompt/capabilities.ts +200 -0
- package/src/runtime/provider/index.ts +106 -5
- package/src/runtime/provider/{setu.ts → ottorouter.ts} +22 -22
- package/src/runtime/provider/reasoning.ts +73 -17
- package/src/runtime/provider/selection.ts +17 -15
- package/src/runtime/session/db-operations.ts +1 -1
- package/src/runtime/session/manager.ts +1 -1
- package/src/runtime/session/queue.ts +7 -2
- package/src/runtime/stream/error-handler.ts +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasToolCall, streamText } from 'ai';
|
|
2
|
+
import { logger } from '@ottocode/sdk';
|
|
2
3
|
import type { getDb } from '@ottocode/database';
|
|
3
4
|
import { messageParts, sessions } from '@ottocode/database/schema';
|
|
4
5
|
import { eq } from 'drizzle-orm';
|
|
@@ -80,6 +81,50 @@ function summarizeTraceValue(value: unknown, max = 160): string {
|
|
|
80
81
|
return fallback.length > max ? `${fallback.slice(0, max)}…` : fallback;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
function nowMs(): number {
|
|
85
|
+
const perf = globalThis.performance;
|
|
86
|
+
if (perf && typeof perf.now === 'function') return perf.now();
|
|
87
|
+
return Date.now();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function approximateMessageChars(
|
|
91
|
+
messages: Array<{ role: string; content: string | Array<unknown> }>,
|
|
92
|
+
): number {
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const message of messages) {
|
|
95
|
+
total += message.role.length;
|
|
96
|
+
if (typeof message.content === 'string') {
|
|
97
|
+
total += message.content.length;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
total += JSON.stringify(message.content).length;
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
return total;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarizeToolShape(tools: Record<string, unknown>) {
|
|
108
|
+
const names = Object.keys(tools);
|
|
109
|
+
const entries = names.map((name) => {
|
|
110
|
+
const toolValue = tools[name];
|
|
111
|
+
let approxChars = 0;
|
|
112
|
+
try {
|
|
113
|
+
approxChars = JSON.stringify(toolValue).length;
|
|
114
|
+
} catch {}
|
|
115
|
+
return { name, approxChars };
|
|
116
|
+
});
|
|
117
|
+
entries.sort((a, b) => b.approxChars - a.approxChars);
|
|
118
|
+
return {
|
|
119
|
+
toolNames: names,
|
|
120
|
+
toolSchemaCharsApprox: entries.reduce(
|
|
121
|
+
(total, entry) => total + entry.approxChars,
|
|
122
|
+
0,
|
|
123
|
+
),
|
|
124
|
+
largestTools: entries.slice(0, 8),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
83
128
|
async function shouldPreemptivelyAutoCompact(
|
|
84
129
|
db: Awaited<ReturnType<typeof getDb>>,
|
|
85
130
|
opts: RunOpts,
|
|
@@ -119,6 +164,8 @@ export async function runSessionLoop(sessionId: string) {
|
|
|
119
164
|
}
|
|
120
165
|
|
|
121
166
|
async function runAssistant(opts: RunOpts) {
|
|
167
|
+
const runStartedAt = nowMs();
|
|
168
|
+
const queueWaitMs = opts.queuedAt ? runStartedAt - opts.queuedAt : 0;
|
|
122
169
|
const setup = await setupRunner(opts);
|
|
123
170
|
const {
|
|
124
171
|
cfg,
|
|
@@ -134,6 +181,7 @@ async function runAssistant(opts: RunOpts) {
|
|
|
134
181
|
providerOptions,
|
|
135
182
|
isOpenAIOAuth,
|
|
136
183
|
mcpToolsRecord,
|
|
184
|
+
timings,
|
|
137
185
|
} = setup;
|
|
138
186
|
let { toolset } = setup;
|
|
139
187
|
|
|
@@ -296,6 +344,25 @@ async function runAssistant(opts: RunOpts) {
|
|
|
296
344
|
|
|
297
345
|
const streamStartTimer = time('runner:first-delta');
|
|
298
346
|
let firstDeltaSeen = false;
|
|
347
|
+
const logFirstOutputLatency = (kind: 'text' | 'reasoning') => {
|
|
348
|
+
if (firstDeltaSeen) return;
|
|
349
|
+
firstDeltaSeen = true;
|
|
350
|
+
const firstOutputMs = nowMs() - runStartedAt;
|
|
351
|
+
streamStartTimer.end({ kind, queueWaitMs, setupMs: timings.totalMs });
|
|
352
|
+
logger.info('[latency] first output', {
|
|
353
|
+
sessionId: opts.sessionId,
|
|
354
|
+
messageId: opts.assistantMessageId,
|
|
355
|
+
agent: opts.agent,
|
|
356
|
+
provider: opts.provider,
|
|
357
|
+
model: opts.model,
|
|
358
|
+
kind,
|
|
359
|
+
queueWaitMs,
|
|
360
|
+
firstOutputMs,
|
|
361
|
+
setupMs: timings.totalMs,
|
|
362
|
+
totalSinceEnqueueMs: queueWaitMs + firstOutputMs,
|
|
363
|
+
timings,
|
|
364
|
+
});
|
|
365
|
+
};
|
|
299
366
|
|
|
300
367
|
let currentPartId: string | null = null;
|
|
301
368
|
let accumulated = '';
|
|
@@ -387,8 +454,39 @@ async function runAssistant(opts: RunOpts) {
|
|
|
387
454
|
const stopWhenCondition = isCopilotResponsesApi
|
|
388
455
|
? undefined
|
|
389
456
|
: hasToolCall('finish');
|
|
457
|
+
const toolShape = summarizeToolShape(toolset as Record<string, unknown>);
|
|
458
|
+
logger.info('[latency] stream request ready', {
|
|
459
|
+
sessionId: opts.sessionId,
|
|
460
|
+
messageId: opts.assistantMessageId,
|
|
461
|
+
agent: opts.agent,
|
|
462
|
+
provider: opts.provider,
|
|
463
|
+
model: opts.model,
|
|
464
|
+
queueWaitMs,
|
|
465
|
+
setupMs: timings.totalMs,
|
|
466
|
+
messageCount: messagesWithSystemInstructions.length,
|
|
467
|
+
toolCount: Object.keys(toolset).length,
|
|
468
|
+
toolNames: toolShape.toolNames,
|
|
469
|
+
toolSchemaCharsApprox: toolShape.toolSchemaCharsApprox,
|
|
470
|
+
largestTools: toolShape.largestTools,
|
|
471
|
+
hasPrepareStep: Boolean(prepareStep),
|
|
472
|
+
providerOptionsKeys: Object.keys(providerOptions),
|
|
473
|
+
systemPromptChars: system.length,
|
|
474
|
+
messageCharsApprox: approximateMessageChars(messagesWithSystemInstructions),
|
|
475
|
+
additionalSystemMessages: additionalSystemMessages.length,
|
|
476
|
+
historyMessages: history.length,
|
|
477
|
+
});
|
|
390
478
|
|
|
391
479
|
try {
|
|
480
|
+
const streamInvocationStartedAt = nowMs();
|
|
481
|
+
logger.info('[latency] streamText invoke', {
|
|
482
|
+
sessionId: opts.sessionId,
|
|
483
|
+
messageId: opts.assistantMessageId,
|
|
484
|
+
agent: opts.agent,
|
|
485
|
+
provider: opts.provider,
|
|
486
|
+
model: opts.model,
|
|
487
|
+
queueWaitMs,
|
|
488
|
+
setupMs: timings.totalMs,
|
|
489
|
+
});
|
|
392
490
|
const result = streamText({
|
|
393
491
|
model,
|
|
394
492
|
tools: toolset,
|
|
@@ -412,10 +510,34 @@ async function runAssistant(opts: RunOpts) {
|
|
|
412
510
|
onFinish: onFinish as any,
|
|
413
511
|
// biome-ignore lint/suspicious/noExplicitAny: AI SDK streamText options type
|
|
414
512
|
} as any);
|
|
513
|
+
logger.info('[latency] streamText returned', {
|
|
514
|
+
sessionId: opts.sessionId,
|
|
515
|
+
messageId: opts.assistantMessageId,
|
|
516
|
+
agent: opts.agent,
|
|
517
|
+
provider: opts.provider,
|
|
518
|
+
model: opts.model,
|
|
519
|
+
invokeMs: nowMs() - streamInvocationStartedAt,
|
|
520
|
+
});
|
|
415
521
|
const tracedToolInputNamesById = new Map<string, string>();
|
|
522
|
+
let firstFullStreamPartSeen = false;
|
|
523
|
+
let firstPublishedDeltaSeen = false;
|
|
416
524
|
|
|
417
525
|
for await (const part of result.fullStream) {
|
|
418
526
|
if (!part) continue;
|
|
527
|
+
if (!firstFullStreamPartSeen) {
|
|
528
|
+
firstFullStreamPartSeen = true;
|
|
529
|
+
logger.info('[latency] first fullStream part', {
|
|
530
|
+
sessionId: opts.sessionId,
|
|
531
|
+
messageId: opts.assistantMessageId,
|
|
532
|
+
agent: opts.agent,
|
|
533
|
+
provider: opts.provider,
|
|
534
|
+
model: opts.model,
|
|
535
|
+
partType: part.type,
|
|
536
|
+
sinceRunStartMs: nowMs() - runStartedAt,
|
|
537
|
+
queueWaitMs,
|
|
538
|
+
setupMs: timings.totalMs,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
419
541
|
|
|
420
542
|
if (part.type === 'tool-input-start') {
|
|
421
543
|
if (shouldTraceToolInput(part.toolName)) {
|
|
@@ -482,10 +604,7 @@ async function runAssistant(opts: RunOpts) {
|
|
|
482
604
|
continue;
|
|
483
605
|
}
|
|
484
606
|
|
|
485
|
-
|
|
486
|
-
firstDeltaSeen = true;
|
|
487
|
-
streamStartTimer.end();
|
|
488
|
-
}
|
|
607
|
+
logFirstOutputLatency('text');
|
|
489
608
|
|
|
490
609
|
if (!currentPartId) {
|
|
491
610
|
currentPartId = crypto.randomUUID();
|
|
@@ -514,6 +633,20 @@ async function runAssistant(opts: RunOpts) {
|
|
|
514
633
|
delta,
|
|
515
634
|
},
|
|
516
635
|
});
|
|
636
|
+
if (!firstPublishedDeltaSeen) {
|
|
637
|
+
firstPublishedDeltaSeen = true;
|
|
638
|
+
logger.info('[latency] first published delta', {
|
|
639
|
+
sessionId: opts.sessionId,
|
|
640
|
+
messageId: opts.assistantMessageId,
|
|
641
|
+
agent: opts.agent,
|
|
642
|
+
provider: opts.provider,
|
|
643
|
+
model: opts.model,
|
|
644
|
+
sinceRunStartMs: nowMs() - runStartedAt,
|
|
645
|
+
queueWaitMs,
|
|
646
|
+
setupMs: timings.totalMs,
|
|
647
|
+
deltaPreview: delta.length > 80 ? `${delta.slice(0, 80)}…` : delta,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
517
650
|
await db
|
|
518
651
|
.update(messageParts)
|
|
519
652
|
.set({ content: JSON.stringify({ text: accumulated }) })
|
|
@@ -537,6 +670,9 @@ async function runAssistant(opts: RunOpts) {
|
|
|
537
670
|
}
|
|
538
671
|
|
|
539
672
|
if (part.type === 'reasoning-delta') {
|
|
673
|
+
if (part.text) {
|
|
674
|
+
logFirstOutputLatency('reasoning');
|
|
675
|
+
}
|
|
540
676
|
await handleReasoningDelta(
|
|
541
677
|
part.id,
|
|
542
678
|
part.text,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
validateProviderModel,
|
|
17
17
|
isProviderAuthorized,
|
|
18
18
|
ensureProviderEnv,
|
|
19
|
-
|
|
19
|
+
hasConfiguredProvider,
|
|
20
20
|
providerEnvVar,
|
|
21
21
|
type ProviderId,
|
|
22
22
|
type ReasoningLevel,
|
|
@@ -134,10 +134,11 @@ async function processAskRequest(
|
|
|
134
134
|
openai: { enabled: true },
|
|
135
135
|
anthropic: { enabled: true },
|
|
136
136
|
google: { enabled: true },
|
|
137
|
+
'ollama-cloud': { enabled: true, baseURL: 'https://ollama.com' },
|
|
137
138
|
openrouter: { enabled: true },
|
|
138
139
|
opencode: { enabled: true },
|
|
139
140
|
copilot: { enabled: true },
|
|
140
|
-
|
|
141
|
+
ottorouter: { enabled: true },
|
|
141
142
|
zai: { enabled: true },
|
|
142
143
|
'zai-coding': { enabled: true },
|
|
143
144
|
moonshot: { enabled: true },
|
|
@@ -204,20 +205,22 @@ async function processAskRequest(
|
|
|
204
205
|
name: agentName,
|
|
205
206
|
prompt: request.agentPrompt,
|
|
206
207
|
tools: request.tools ?? ['progress_update', 'finish'],
|
|
207
|
-
provider:
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
provider:
|
|
209
|
+
typeof request.provider === 'string' &&
|
|
210
|
+
hasConfiguredProvider(cfg, request.provider)
|
|
211
|
+
? request.provider
|
|
212
|
+
: undefined,
|
|
210
213
|
model: request.model,
|
|
211
214
|
}
|
|
212
215
|
: await resolveAgentConfig(cfg.projectRoot, agentName);
|
|
213
216
|
agentTimer.end({ agent: agentName });
|
|
214
|
-
const agentProviderDefault =
|
|
217
|
+
const agentProviderDefault = hasConfiguredProvider(cfg, agentCfg.provider)
|
|
215
218
|
? agentCfg.provider
|
|
216
219
|
: cfg.defaults.provider;
|
|
217
220
|
const agentModelDefault = agentCfg.model ?? cfg.defaults.model;
|
|
218
221
|
|
|
219
|
-
const explicitProvider =
|
|
220
|
-
?
|
|
222
|
+
const explicitProvider = hasConfiguredProvider(cfg, request.provider)
|
|
223
|
+
? request.provider
|
|
221
224
|
: undefined;
|
|
222
225
|
|
|
223
226
|
let providerSelection: ProviderSelection;
|
|
@@ -265,8 +268,8 @@ async function processAskRequest(
|
|
|
265
268
|
providerForMessage = providerSelection.provider;
|
|
266
269
|
modelForMessage = providerSelection.model;
|
|
267
270
|
} else if (session.provider && session.model) {
|
|
268
|
-
const sessionProvider =
|
|
269
|
-
?
|
|
271
|
+
const sessionProvider = hasConfiguredProvider(cfg, session.provider)
|
|
272
|
+
? session.provider
|
|
270
273
|
: agentProviderDefault;
|
|
271
274
|
providerForMessage = sessionProvider;
|
|
272
275
|
modelForMessage = session.model;
|
|
@@ -302,7 +305,7 @@ async function processAskRequest(
|
|
|
302
305
|
} as SessionRow;
|
|
303
306
|
}
|
|
304
307
|
|
|
305
|
-
validateProviderModel(providerForMessage, modelForMessage);
|
|
308
|
+
validateProviderModel(providerForMessage, modelForMessage, cfg);
|
|
306
309
|
|
|
307
310
|
if (!request.skipFileConfig && !request.config && !request.credentials) {
|
|
308
311
|
await ensureProviderEnv(cfg, providerForMessage);
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from 'ai';
|
|
8
8
|
import type { getDb } from '@ottocode/database';
|
|
9
9
|
import { messages, messageParts } from '@ottocode/database/schema';
|
|
10
|
-
import { eq, asc } from 'drizzle-orm';
|
|
10
|
+
import { eq, asc, inArray } from 'drizzle-orm';
|
|
11
11
|
import { ToolHistoryTracker } from './tool-history-tracker.ts';
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -24,16 +24,32 @@ export async function buildHistoryMessages(
|
|
|
24
24
|
.from(messages)
|
|
25
25
|
.where(eq(messages.sessionId, sessionId))
|
|
26
26
|
.orderBy(asc(messages.createdAt));
|
|
27
|
+
const messageIds = rows.map((row) => row.id);
|
|
28
|
+
const allParts = messageIds.length
|
|
29
|
+
? await db
|
|
30
|
+
.select()
|
|
31
|
+
.from(messageParts)
|
|
32
|
+
.where(inArray(messageParts.messageId, messageIds))
|
|
33
|
+
.orderBy(asc(messageParts.messageId), asc(messageParts.index))
|
|
34
|
+
: [];
|
|
35
|
+
const partsByMessageId = new Map<
|
|
36
|
+
string,
|
|
37
|
+
(typeof messageParts.$inferSelect)[]
|
|
38
|
+
>();
|
|
39
|
+
for (const part of allParts) {
|
|
40
|
+
const existing = partsByMessageId.get(part.messageId);
|
|
41
|
+
if (existing) {
|
|
42
|
+
existing.push(part);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
partsByMessageId.set(part.messageId, [part]);
|
|
46
|
+
}
|
|
27
47
|
|
|
28
48
|
const history: ModelMessage[] = [];
|
|
29
49
|
const toolHistory = new ToolHistoryTracker();
|
|
30
50
|
|
|
31
51
|
for (const m of rows) {
|
|
32
|
-
const parts =
|
|
33
|
-
.select()
|
|
34
|
-
.from(messageParts)
|
|
35
|
-
.where(eq(messageParts.messageId, m.id))
|
|
36
|
-
.orderBy(asc(messageParts.index));
|
|
52
|
+
const parts = partsByMessageId.get(m.id) ?? [];
|
|
37
53
|
|
|
38
54
|
if (
|
|
39
55
|
m.role === 'assistant' &&
|
|
@@ -9,6 +9,7 @@ import { runSessionLoop } from '../agent/runner.ts';
|
|
|
9
9
|
import { resolveModel } from '../provider/index.ts';
|
|
10
10
|
import {
|
|
11
11
|
getFastModelForAuth,
|
|
12
|
+
getProviderDefinition,
|
|
12
13
|
logger,
|
|
13
14
|
type ProviderId,
|
|
14
15
|
type ReasoningLevel,
|
|
@@ -316,8 +317,13 @@ async function generateSessionTitle(args: {
|
|
|
316
317
|
const { getAuth } = await import('@ottocode/sdk');
|
|
317
318
|
const auth = await getAuth(provider, cfg.projectRoot);
|
|
318
319
|
const oauth = detectOAuth(provider, auth);
|
|
320
|
+
const providerDefinition = getProviderDefinition(cfg, provider);
|
|
319
321
|
|
|
320
|
-
const titleModel =
|
|
322
|
+
const titleModel =
|
|
323
|
+
providerDefinition?.source === 'custom' ||
|
|
324
|
+
providerDefinition?.compatibility === 'ollama'
|
|
325
|
+
? modelName
|
|
326
|
+
: (getFastModelForAuth(provider, auth?.type) ?? modelName);
|
|
321
327
|
const model = await resolveModel(provider, titleModel, cfg);
|
|
322
328
|
|
|
323
329
|
const promptText = String(content ?? '').slice(0, 2000);
|
|
@@ -20,6 +20,7 @@ import ANTHROPIC_SPOOF_PROMPT from '@ottocode/sdk/prompts/providers/anthropicSpo
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
import { getTerminalManager } from '@ottocode/sdk';
|
|
23
|
+
import { buildCapabilitySummary } from './capabilities.ts';
|
|
23
24
|
|
|
24
25
|
export type ComposedSystemPrompt = {
|
|
25
26
|
prompt: string;
|
|
@@ -29,6 +30,8 @@ export type ComposedSystemPrompt = {
|
|
|
29
30
|
export async function composeSystemPrompt(options: {
|
|
30
31
|
provider: string;
|
|
31
32
|
model?: string;
|
|
33
|
+
promptFamily?: import('@ottocode/sdk').ProviderPromptFamily | null;
|
|
34
|
+
skillSettings?: import('@ottocode/sdk').OttoConfig['skills'];
|
|
32
35
|
projectRoot: string;
|
|
33
36
|
agentPrompt: string;
|
|
34
37
|
oneShot?: boolean;
|
|
@@ -68,6 +71,7 @@ export async function composeSystemPrompt(options: {
|
|
|
68
71
|
options.provider,
|
|
69
72
|
options.model,
|
|
70
73
|
options.projectRoot,
|
|
74
|
+
options.promptFamily ?? undefined,
|
|
71
75
|
);
|
|
72
76
|
const baseInstructions = (BASE_PROMPT || '').trim();
|
|
73
77
|
const agentInstructions = options.agentPrompt.trim();
|
|
@@ -126,6 +130,14 @@ export async function composeSystemPrompt(options: {
|
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
const capabilitySummary = buildCapabilitySummary({
|
|
134
|
+
skillSettings: options.skillSettings,
|
|
135
|
+
});
|
|
136
|
+
if (capabilitySummary.prompt) {
|
|
137
|
+
parts.push(capabilitySummary.prompt);
|
|
138
|
+
components.push(...capabilitySummary.components);
|
|
139
|
+
}
|
|
140
|
+
|
|
129
141
|
// Add user-provided context if present
|
|
130
142
|
if (options.userContext?.trim()) {
|
|
131
143
|
const userContextBlock = [
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterDiscoveredSkills,
|
|
3
|
+
getDiscoveredSkills,
|
|
4
|
+
getMCPManager,
|
|
5
|
+
summarizeDescription,
|
|
6
|
+
type DiscoveredSkill,
|
|
7
|
+
type OttoConfig,
|
|
8
|
+
} from '@ottocode/sdk';
|
|
9
|
+
|
|
10
|
+
const MAX_SKILLS = 8;
|
|
11
|
+
const MAX_MCP_SERVERS = 8;
|
|
12
|
+
const MAX_MCP_TOOLS_PER_SERVER = 3;
|
|
13
|
+
|
|
14
|
+
export type CapabilitySummaryMCPTool = {
|
|
15
|
+
name: string;
|
|
16
|
+
server: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CapabilitySummaryResult = {
|
|
21
|
+
prompt: string;
|
|
22
|
+
components: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a compact prompt block that advertises available skills and started MCP
|
|
27
|
+
* servers without inlining their full instructions or tool catalogs.
|
|
28
|
+
*/
|
|
29
|
+
export function buildCapabilitySummary(options?: {
|
|
30
|
+
skillSettings?: OttoConfig['skills'];
|
|
31
|
+
skills?: DiscoveredSkill[];
|
|
32
|
+
mcpTools?: CapabilitySummaryMCPTool[];
|
|
33
|
+
}): CapabilitySummaryResult {
|
|
34
|
+
const skillLines = buildSkillLines(options?.skills, options?.skillSettings);
|
|
35
|
+
const mcpLines = buildMCPLines(options?.mcpTools);
|
|
36
|
+
const components = ['capabilities'];
|
|
37
|
+
const sections: string[] = [];
|
|
38
|
+
|
|
39
|
+
if (skillLines.length > 0) {
|
|
40
|
+
sections.push(['Skills:', ...skillLines].join('\n'));
|
|
41
|
+
components.push('capabilities:skills');
|
|
42
|
+
}
|
|
43
|
+
if (mcpLines.length > 0) {
|
|
44
|
+
sections.push(['Started MCP capabilities:', ...mcpLines].join('\n'));
|
|
45
|
+
components.push('capabilities:mcp');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (sections.length === 0) {
|
|
49
|
+
return { prompt: '', components: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const prompt = [
|
|
53
|
+
'<optional-capabilities>',
|
|
54
|
+
'You have additional capabilities available when they may help with the task.',
|
|
55
|
+
'Use them proactively when relevant, but do not load or call them unnecessarily.',
|
|
56
|
+
'',
|
|
57
|
+
sections.join('\n\n'),
|
|
58
|
+
'',
|
|
59
|
+
'When one of these capabilities may help, prefer using it instead of ignoring it.',
|
|
60
|
+
'</optional-capabilities>',
|
|
61
|
+
].join('\n');
|
|
62
|
+
|
|
63
|
+
return { prompt, components };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildSkillLines(
|
|
67
|
+
providedSkills: DiscoveredSkill[] | undefined,
|
|
68
|
+
skillSettings: OttoConfig['skills'] | undefined,
|
|
69
|
+
): string[] {
|
|
70
|
+
const skills = filterDiscoveredSkills(
|
|
71
|
+
providedSkills ?? getDiscoveredSkills(),
|
|
72
|
+
skillSettings,
|
|
73
|
+
);
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const unique: DiscoveredSkill[] = [];
|
|
76
|
+
|
|
77
|
+
for (const skill of skills) {
|
|
78
|
+
const name = skill.name.trim();
|
|
79
|
+
if (!name || seen.has(name)) continue;
|
|
80
|
+
seen.add(name);
|
|
81
|
+
unique.push(skill);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
unique.sort((a, b) => a.name.localeCompare(b.name));
|
|
85
|
+
|
|
86
|
+
const visible = unique.slice(0, MAX_SKILLS).map((skill) => {
|
|
87
|
+
const summary = finalizeSentence(summarizeDescription(skill.description));
|
|
88
|
+
const description = summary || 'Task-specific instructions and guidance';
|
|
89
|
+
return `- ${skill.name}: ${description}. Load with \`skill\` when it matches the task.`;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const remaining = unique.length - visible.length;
|
|
93
|
+
if (remaining > 0) {
|
|
94
|
+
visible.push(
|
|
95
|
+
`- ${remaining} more skill${remaining === 1 ? '' : 's'} available via \`skill\`.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return visible;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildMCPLines(
|
|
103
|
+
providedMCPTools: CapabilitySummaryMCPTool[] | undefined,
|
|
104
|
+
): string[] {
|
|
105
|
+
const tools = providedMCPTools ?? getLiveMCPTools();
|
|
106
|
+
if (tools.length === 0) return [];
|
|
107
|
+
|
|
108
|
+
const grouped = new Map<string, CapabilitySummaryMCPTool[]>();
|
|
109
|
+
for (const tool of tools) {
|
|
110
|
+
const list = grouped.get(tool.server) ?? [];
|
|
111
|
+
list.push(tool);
|
|
112
|
+
grouped.set(tool.server, list);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const servers = Array.from(grouped.entries()).sort(([a], [b]) =>
|
|
116
|
+
a.localeCompare(b),
|
|
117
|
+
);
|
|
118
|
+
const visible = servers
|
|
119
|
+
.slice(0, MAX_MCP_SERVERS)
|
|
120
|
+
.map(([server, serverTools]) => {
|
|
121
|
+
const summary = summarizeMCPServer(server, serverTools);
|
|
122
|
+
return `- ${server}: ${summary}. Load relevant tools with \`load_mcp_tools\` when the task may benefit from them.`;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const remaining = servers.length - visible.length;
|
|
126
|
+
if (remaining > 0) {
|
|
127
|
+
visible.push(
|
|
128
|
+
`- ${remaining} more started MCP server${remaining === 1 ? '' : 's'} available via \`load_mcp_tools\`.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return visible;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getLiveMCPTools(): CapabilitySummaryMCPTool[] {
|
|
136
|
+
const manager = getMCPManager();
|
|
137
|
+
if (!manager?.started) return [];
|
|
138
|
+
return manager.getTools().map(({ name, server, tool }) => ({
|
|
139
|
+
name,
|
|
140
|
+
server,
|
|
141
|
+
description: tool.description,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function summarizeMCPServer(
|
|
146
|
+
server: string,
|
|
147
|
+
tools: CapabilitySummaryMCPTool[],
|
|
148
|
+
): string {
|
|
149
|
+
const namedTools = dedupeStrings(
|
|
150
|
+
tools
|
|
151
|
+
.map((tool) => stripServerPrefix(tool.name, server))
|
|
152
|
+
.filter((name) => name.length > 0),
|
|
153
|
+
);
|
|
154
|
+
const representativeNames = namedTools.slice(0, MAX_MCP_TOOLS_PER_SERVER);
|
|
155
|
+
const descriptiveText = dedupeStrings(
|
|
156
|
+
tools
|
|
157
|
+
.map((tool) => tool.description?.trim() ?? '')
|
|
158
|
+
.filter((description) => description.length > 0)
|
|
159
|
+
.map((description) => description.replace(/^MCP tool:\s*/i, '')),
|
|
160
|
+
).map((description) => finalizeSentence(description));
|
|
161
|
+
const summaryFromDescription = descriptiveText.find(
|
|
162
|
+
(description) => description.length > 0,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (summaryFromDescription) {
|
|
166
|
+
if (representativeNames.length === 0) {
|
|
167
|
+
return summaryFromDescription;
|
|
168
|
+
}
|
|
169
|
+
return `${summaryFromDescription}; tools include ${representativeNames.join(', ')}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (representativeNames.length === 0) {
|
|
173
|
+
return `external tools exposed by the ${server} MCP server`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return `external ${server} tools such as ${representativeNames.join(', ')}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stripServerPrefix(name: string, server: string): string {
|
|
180
|
+
const prefix = `${server}__`;
|
|
181
|
+
return name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function finalizeSentence(value: string): string {
|
|
185
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
186
|
+
if (!normalized) return '';
|
|
187
|
+
return normalized.replace(/[.!?;:,\s]+$/g, '');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function dedupeStrings(values: string[]): string[] {
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
const out: string[] = [];
|
|
193
|
+
for (const value of values) {
|
|
194
|
+
const normalized = value.trim();
|
|
195
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
196
|
+
seen.add(normalized);
|
|
197
|
+
out.push(normalized);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|