@danieltmn/tamnora 0.5.1 → 0.6.0
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/CHANGELOG.md +7 -0
- package/harness/packages/core/src/config/index.js +1 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/storage/sqlite.js +108 -2
- package/harness/packages/core/src/tools/labels.js +83 -0
- package/harness/packages/core/test/labels.test.js +34 -0
- package/harness/packages/server/public/app.js +436 -110
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +95 -4
- package/harness/packages/server/src/lib/channels/index.js +43 -0
- package/harness/packages/server/src/lib/{telegram.js → channels/telegram.js} +157 -26
- package/harness/packages/server/src/lib/http.js +3 -2
- package/harness/packages/server/src/lib/runManager.js +102 -0
- package/harness/packages/server/src/lib/tasks.js +126 -0
- package/harness/packages/server/src/routes/chat.js +161 -77
- package/harness/packages/server/src/routes/sessions.js +17 -0
- package/harness/packages/server/src/server.js +12 -16
- package/harness/packages/server/test/server.test.js +129 -1
- package/harness/tamnora.example.json +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Tool "task": el agente principal delega una subtarea a un subagente con
|
|
2
|
+
// contexto propio y un toolset acotado (solo lectura por defecto). Los eventos
|
|
3
|
+
// del subagente se emiten con `agentId` para que la consola los anide, y su
|
|
4
|
+
// resumen final se devuelve como resultado de la tool.
|
|
5
|
+
|
|
6
|
+
import { runAgent, createTools, resolveMode, summarizeToolCall, previewToolResult } from './core.js';
|
|
7
|
+
|
|
8
|
+
const SUBAGENT_PROMPT = [
|
|
9
|
+
'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
|
|
10
|
+
'usando las herramientas disponibles y devolvé un resumen conciso y concreto de',
|
|
11
|
+
'lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas',
|
|
12
|
+
'confirmaciones: si algo no es posible, explicalo en el resumen.',
|
|
13
|
+
].join(' ');
|
|
14
|
+
|
|
15
|
+
function definition(name, description, parameters, required = []) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'function',
|
|
18
|
+
function: { name, description, parameters: { type: 'object', properties: parameters, required } },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Crea la tool `task` para una sesión/run y la función `execute`.
|
|
24
|
+
* `emit(type, data, agentId)` persiste y difunde el evento (via RunManager).
|
|
25
|
+
*/
|
|
26
|
+
export function createSubagentTool(ctx, options) {
|
|
27
|
+
const {
|
|
28
|
+
sessionId,
|
|
29
|
+
cwd,
|
|
30
|
+
providerId,
|
|
31
|
+
model,
|
|
32
|
+
permissions = {},
|
|
33
|
+
mcp = null,
|
|
34
|
+
signal,
|
|
35
|
+
exclude = [],
|
|
36
|
+
emit,
|
|
37
|
+
} = options;
|
|
38
|
+
|
|
39
|
+
const definitions = [
|
|
40
|
+
definition(
|
|
41
|
+
'task',
|
|
42
|
+
'Delega una subtarea a un subagente con su propio contexto (solo lectura por defecto). Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
|
|
43
|
+
{
|
|
44
|
+
description: { type: 'string', description: 'Título corto de la subtarea' },
|
|
45
|
+
prompt: { type: 'string', description: 'Instrucción completa y autocontenida para el subagente' },
|
|
46
|
+
mode: { type: 'string', enum: ['plan', 'agent'], description: 'plan (solo lectura, default) o agent' },
|
|
47
|
+
tools: { type: 'array', items: { type: 'string' }, description: 'Limitar las tools del subagente' },
|
|
48
|
+
},
|
|
49
|
+
['prompt']
|
|
50
|
+
),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const handles = (name) => name === 'task';
|
|
54
|
+
|
|
55
|
+
async function execute(name, args = {}) {
|
|
56
|
+
if (!handles(name)) return `Herramienta desconocida: ${name}`;
|
|
57
|
+
const prompt = String(args.prompt ?? args.task ?? '').trim();
|
|
58
|
+
const description = String(args.description ?? prompt).slice(0, 80).trim() || 'subtarea';
|
|
59
|
+
if (!prompt) return 'La subtarea necesita "prompt".';
|
|
60
|
+
|
|
61
|
+
const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title: description, model });
|
|
62
|
+
const mode = resolveMode(args.mode ?? 'plan');
|
|
63
|
+
emit('agent_start', { agentId, description, mode: mode.id, model }, agentId);
|
|
64
|
+
|
|
65
|
+
const enabled =
|
|
66
|
+
Array.isArray(args.tools) && args.tools.length
|
|
67
|
+
? args.tools.filter((tool) => !exclude.includes(tool))
|
|
68
|
+
: mode.tools ?? undefined;
|
|
69
|
+
const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp });
|
|
70
|
+
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
71
|
+
const messages = [
|
|
72
|
+
{ role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
|
|
73
|
+
{ role: 'user', content: prompt },
|
|
74
|
+
];
|
|
75
|
+
const { provider, config } = ctx.resolveProvider({
|
|
76
|
+
...ctx.providerSettings(providerId),
|
|
77
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await runAgent({
|
|
82
|
+
provider,
|
|
83
|
+
config,
|
|
84
|
+
model,
|
|
85
|
+
messages,
|
|
86
|
+
tools: local.definitions,
|
|
87
|
+
execute: local.execute,
|
|
88
|
+
signal,
|
|
89
|
+
maxRounds: ctx.config.agents?.maxRounds ?? 6,
|
|
90
|
+
onEvent(event) {
|
|
91
|
+
if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
|
|
92
|
+
else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
|
|
93
|
+
else if (event.type === 'tool_call') {
|
|
94
|
+
emit(
|
|
95
|
+
'tool_call',
|
|
96
|
+
{ id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args), agentId },
|
|
97
|
+
agentId
|
|
98
|
+
);
|
|
99
|
+
} else if (event.type === 'tool_result') {
|
|
100
|
+
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
101
|
+
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
102
|
+
emit('tool_result', { id: event.id, name: event.name, preview, truncated, failed, agentId }, agentId);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
ctx.store.addMessages(
|
|
108
|
+
sessionId,
|
|
109
|
+
result.messages.slice(messages.length).map((message) => ({ ...message, agent_id: agentId }))
|
|
110
|
+
);
|
|
111
|
+
const summary = (result.content ?? '').trim() || '(sin resumen)';
|
|
112
|
+
const status = result.stopReason === 'aborted' ? 'aborted' : 'done';
|
|
113
|
+
ctx.store.finishAgent(agentId, { status, summary });
|
|
114
|
+
emit('agent_end', { agentId, status, content: summary, rounds: result.rounds, tokens: result.usage ?? null }, agentId);
|
|
115
|
+
return `Subagente "${description}" (${status}):\n\n${summary}`;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const status = error?.name === 'AbortError' ? 'aborted' : 'error';
|
|
118
|
+
const message = String(error?.message ?? error);
|
|
119
|
+
ctx.store.finishAgent(agentId, { status, summary: message });
|
|
120
|
+
emit('agent_end', { agentId, status, content: message }, agentId);
|
|
121
|
+
return `Subagente "${description}" falló (${status}): ${message}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { definitions, handles, execute };
|
|
126
|
+
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
runAgent,
|
|
4
|
+
createTools,
|
|
5
|
+
resolveMode,
|
|
6
|
+
MAX_TOOL_ROUNDS,
|
|
7
|
+
summarizeToolCall,
|
|
8
|
+
previewToolResult,
|
|
9
|
+
} from '../lib/core.js';
|
|
3
10
|
import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
11
|
import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
|
|
12
|
+
import { createSubagentTool } from '../lib/tasks.js';
|
|
5
13
|
|
|
6
14
|
/**
|
|
7
15
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -49,6 +57,43 @@ export function registerChat(route, ctx) {
|
|
|
49
57
|
sendJson(res, 200, { ok: true, allow, scope });
|
|
50
58
|
});
|
|
51
59
|
|
|
60
|
+
route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
|
|
61
|
+
if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
|
|
62
|
+
ctx.runs.cancel(params.id);
|
|
63
|
+
sendJson(res, 200, { ok: true, stopped: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Reanuda un turno en curso: reproduce los eventos guardados y sigue en vivo.
|
|
67
|
+
route('GET', '/api/sessions/:id/stream', ({ req, res, params, query }) => {
|
|
68
|
+
if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
|
|
69
|
+
const from = Number(query.get('from') ?? req.headers['last-event-id'] ?? 0) || 0;
|
|
70
|
+
const sse = openSse(req, res);
|
|
71
|
+
|
|
72
|
+
const buffered = [];
|
|
73
|
+
let replaying = true;
|
|
74
|
+
const unsubscribe = ctx.runs.subscribe(params.id, (event) => {
|
|
75
|
+
if (replaying) buffered.push(event);
|
|
76
|
+
else sse.event(event.type, event.data, event.id);
|
|
77
|
+
});
|
|
78
|
+
const stored = ctx.store.getSessionEvents(params.id, { from });
|
|
79
|
+
let cursor = from;
|
|
80
|
+
for (const event of stored) {
|
|
81
|
+
sse.event(event.type, event.data, event.id);
|
|
82
|
+
cursor = event.id;
|
|
83
|
+
}
|
|
84
|
+
replaying = false;
|
|
85
|
+
for (const event of buffered) {
|
|
86
|
+
if (event.id > cursor) sse.event(event.type, event.data, event.id);
|
|
87
|
+
}
|
|
88
|
+
const active = ctx.runs.isActive(params.id);
|
|
89
|
+
sse.event('resumed', { active, cursor });
|
|
90
|
+
req.on('close', unsubscribe);
|
|
91
|
+
if (!active) {
|
|
92
|
+
sse.event('idle', {});
|
|
93
|
+
sse.close();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
52
97
|
route('POST', '/api/chat', async ({ req, res }) => {
|
|
53
98
|
const body = await readJson(req);
|
|
54
99
|
const input = String(body.input ?? '').trim();
|
|
@@ -87,25 +132,28 @@ export function registerChat(route, ctx) {
|
|
|
87
132
|
});
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
|
|
135
|
+
if (ctx.runs.isActive(sessionId)) {
|
|
136
|
+
return sendError(res, 409, 'La sesión ya tiene una respuesta en curso.');
|
|
137
|
+
}
|
|
91
138
|
|
|
139
|
+
const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
|
|
92
140
|
const sse = openSse(req, res);
|
|
93
|
-
|
|
141
|
+
ctx.runs.start(sessionId);
|
|
142
|
+
const unsubscribe = ctx.runs.subscribe(sessionId, (event) => sse.event(event.type, event.data, event.id));
|
|
143
|
+
// Desconectar no cancela: el run sigue y los eventos quedan persistidos.
|
|
144
|
+
req.on('close', () => unsubscribe());
|
|
145
|
+
|
|
94
146
|
const autoApprove = body.yes ?? ctx.yes;
|
|
95
147
|
const asked = new Set();
|
|
96
|
-
req.on('close', () => {
|
|
97
|
-
controller.abort();
|
|
98
|
-
ctx.approvals.cancel(asked);
|
|
99
|
-
});
|
|
100
148
|
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
ctx.runs.emit(sessionId, 'start', { sessionId, provider: providerId, model, mode: mode.id, cwd: sessionCwd });
|
|
150
|
+
ctx.runs.emit(sessionId, 'user', { content: input });
|
|
103
151
|
|
|
104
152
|
let mcp = null;
|
|
105
153
|
try {
|
|
106
154
|
mcp = await ctx.getMcp();
|
|
107
155
|
} catch (error) {
|
|
108
|
-
|
|
156
|
+
ctx.runs.emit(sessionId, 'warning', { message: `MCP: ${error?.message ?? error}` });
|
|
109
157
|
}
|
|
110
158
|
|
|
111
159
|
const enabled = Array.isArray(body.tools) && body.tools.length ? body.tools : mode.tools ?? undefined;
|
|
@@ -116,11 +164,11 @@ export function registerChat(route, ctx) {
|
|
|
116
164
|
const askId = randomUUID();
|
|
117
165
|
asked.add(askId);
|
|
118
166
|
askMeta.set(askId, { sessionId });
|
|
119
|
-
|
|
167
|
+
ctx.runs.emit(sessionId, 'permission', { askId, sessionId, name, args });
|
|
120
168
|
const allow = await ctx.approvals.ask(askId);
|
|
121
169
|
asked.delete(askId);
|
|
122
170
|
askMeta.delete(askId);
|
|
123
|
-
|
|
171
|
+
ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
|
|
124
172
|
return allow;
|
|
125
173
|
};
|
|
126
174
|
const local = createTools({
|
|
@@ -134,11 +182,29 @@ export function registerChat(route, ctx) {
|
|
|
134
182
|
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
135
183
|
const exposeAdmin = !mode.tools;
|
|
136
184
|
const admin = ctx.agentTools;
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
185
|
+
const emitAgent = (type, data, agentId = null) => ctx.runs.emit(sessionId, type, data, { agentId });
|
|
186
|
+
const tasks = createSubagentTool(ctx, {
|
|
187
|
+
sessionId,
|
|
188
|
+
cwd: sessionCwd,
|
|
189
|
+
providerId,
|
|
190
|
+
model,
|
|
191
|
+
permissions: ctx.config.permissions,
|
|
192
|
+
mcp,
|
|
193
|
+
signal: ctx.runs.signal(sessionId),
|
|
194
|
+
exclude: admin.definitions.map((d) => d.function.name),
|
|
195
|
+
emit: emitAgent,
|
|
196
|
+
});
|
|
197
|
+
const definitions = [
|
|
198
|
+
...(exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions),
|
|
199
|
+
...tasks.definitions,
|
|
200
|
+
];
|
|
201
|
+
const execute = (name, args, context) => {
|
|
202
|
+
if (tasks.handles(name)) return tasks.execute(name, args);
|
|
203
|
+
if (exposeAdmin && admin.handles(name)) {
|
|
204
|
+
return admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk });
|
|
205
|
+
}
|
|
206
|
+
return local.execute(name, args, context);
|
|
207
|
+
};
|
|
142
208
|
|
|
143
209
|
const { provider, config } = ctx.resolveProvider({
|
|
144
210
|
...ctx.providerSettings(providerId),
|
|
@@ -167,66 +233,84 @@ export function registerChat(route, ctx) {
|
|
|
167
233
|
...history,
|
|
168
234
|
userMessage,
|
|
169
235
|
];
|
|
236
|
+
// Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
|
|
237
|
+
// reconexion puede reconstruir el turno desde el historial.
|
|
238
|
+
ctx.store.addMessage(sessionId, userMessage);
|
|
239
|
+
const prefixLength = messages.length;
|
|
170
240
|
|
|
171
|
-
|
|
172
|
-
const partials = createPartialThrottle(300, (name, payload) =>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
241
|
+
(async () => {
|
|
242
|
+
const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
|
|
243
|
+
try {
|
|
244
|
+
const result = await runAgent({
|
|
245
|
+
provider,
|
|
246
|
+
config,
|
|
247
|
+
model,
|
|
248
|
+
messages,
|
|
249
|
+
tools: definitions,
|
|
250
|
+
execute,
|
|
251
|
+
signal: ctx.runs.signal(sessionId),
|
|
252
|
+
maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
|
|
253
|
+
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
254
|
+
contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
|
|
255
|
+
onEvent(event) {
|
|
256
|
+
if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
|
|
257
|
+
else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
|
|
258
|
+
else if (event.type === 'stall') {
|
|
259
|
+
partials.flush();
|
|
260
|
+
ctx.runs.emit(sessionId, 'stall', { seconds: event.seconds });
|
|
261
|
+
} else if (event.type === 'retry') {
|
|
262
|
+
partials.flush();
|
|
263
|
+
ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
|
|
264
|
+
} else if (event.type === 'trim') {
|
|
265
|
+
partials.flush();
|
|
266
|
+
ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
|
|
267
|
+
} else if (event.type === 'tool_round') {
|
|
268
|
+
partials.flush();
|
|
269
|
+
ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
|
|
270
|
+
} else if (event.type === 'tool_call') {
|
|
271
|
+
partials.flush();
|
|
272
|
+
ctx.runs.emit(sessionId, 'tool_call', {
|
|
273
|
+
id: event.id,
|
|
274
|
+
name: event.name,
|
|
275
|
+
args: event.args,
|
|
276
|
+
summary: summarizeToolCall(event.name, event.args),
|
|
277
|
+
});
|
|
278
|
+
} else if (event.type === 'tool_result') {
|
|
279
|
+
partials.flush();
|
|
280
|
+
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
281
|
+
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
282
|
+
ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
partials.flush();
|
|
287
|
+
|
|
288
|
+
const newMessages = result.messages
|
|
289
|
+
.slice(prefixLength)
|
|
290
|
+
.map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
|
|
291
|
+
ctx.store.addMessages(sessionId, newMessages);
|
|
292
|
+
const current = ctx.store.getSession(sessionId);
|
|
293
|
+
const title =
|
|
294
|
+
current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
|
|
295
|
+
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
296
|
+
if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', {});
|
|
297
|
+
else
|
|
298
|
+
ctx.runs.emit(sessionId, 'done', {
|
|
299
|
+
sessionId,
|
|
300
|
+
content: result.content,
|
|
301
|
+
rounds: result.rounds,
|
|
302
|
+
stopReason: result.stopReason,
|
|
303
|
+
tokens: result.usage ?? null,
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
partials.flush();
|
|
307
|
+
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
|
|
308
|
+
else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
|
|
309
|
+
} finally {
|
|
310
|
+
unsubscribe();
|
|
311
|
+
sse.close();
|
|
312
|
+
ctx.runs.finish(sessionId);
|
|
313
|
+
}
|
|
314
|
+
})();
|
|
231
315
|
});
|
|
232
316
|
}
|
|
@@ -76,9 +76,26 @@ export function registerSessions(route, ctx) {
|
|
|
76
76
|
session: view(session),
|
|
77
77
|
tokens: ctx.store.sessionTokens(params.id),
|
|
78
78
|
messages: ctx.store.getMessages(params.id),
|
|
79
|
+
running: ctx.runs?.isActive(params.id) ?? false,
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
route('GET', '/api/sessions/:id/agents', ({ res, params }) => {
|
|
84
|
+
if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
|
|
85
|
+
sendJson(res, 200, { agents: ctx.store.listAgents(params.id) });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
route('GET', '/api/sessions/:id/tool-result', ({ res, params, query }) => {
|
|
89
|
+
if (!ctx.store.getSession(params.id)) return sendError(res, 404, 'Sesión no encontrada.');
|
|
90
|
+
const callId = String(query.get('callId') || '');
|
|
91
|
+
if (!callId) return sendError(res, 400, 'Falta "callId".');
|
|
92
|
+
const message = ctx.store.getMessages(params.id).find((m) => m.tool_call_id === callId);
|
|
93
|
+
if (!message) return sendError(res, 404, 'Resultado no encontrado.');
|
|
94
|
+
const content =
|
|
95
|
+
typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2);
|
|
96
|
+
sendJson(res, 200, { callId, name: message.name ?? null, content });
|
|
97
|
+
});
|
|
98
|
+
|
|
82
99
|
route('PUT', '/api/sessions/:id', async ({ req, res, params }) => {
|
|
83
100
|
const session = ctx.store.getSession(params.id);
|
|
84
101
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
@@ -18,8 +18,9 @@ import { sendJson, sendError } from './lib/http.js';
|
|
|
18
18
|
import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
19
19
|
import { skillsPrompt } from './lib/skills.js';
|
|
20
20
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
21
|
+
import { createRunManager } from './lib/runManager.js';
|
|
21
22
|
import { createProcManager } from './lib/procs.js';
|
|
22
|
-
import {
|
|
23
|
+
import { startChannels } from './lib/channels/index.js';
|
|
23
24
|
import { createAgentTools } from './lib/agentTools.js';
|
|
24
25
|
import { createStaticHandler } from './static.js';
|
|
25
26
|
import { requireLogin, requireCsrf } from './auth.js';
|
|
@@ -71,6 +72,7 @@ export async function createTamnoraServer({
|
|
|
71
72
|
const authEnabled = Boolean(app.user && app.user.password);
|
|
72
73
|
const store = new Store(dbPath ?? databasePath(cwd));
|
|
73
74
|
const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
|
|
75
|
+
const runs = createRunManager({ store });
|
|
74
76
|
const procs = createProcManager({ allow: config.processes?.allow });
|
|
75
77
|
const procsEnabled = config.processes?.enabled !== false;
|
|
76
78
|
const autoApproved = new Set();
|
|
@@ -142,6 +144,7 @@ export async function createTamnoraServer({
|
|
|
142
144
|
authEnabled,
|
|
143
145
|
store,
|
|
144
146
|
approvals,
|
|
147
|
+
runs,
|
|
145
148
|
isAutoApprove,
|
|
146
149
|
setAutoApprove,
|
|
147
150
|
yes,
|
|
@@ -167,20 +170,12 @@ export async function createTamnoraServer({
|
|
|
167
170
|
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
168
171
|
ctx.agentTools = createAgentTools(ctx);
|
|
169
172
|
|
|
170
|
-
//
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
telegram && tgToken
|
|
177
|
-
? startTelegramBot(ctx, {
|
|
178
|
-
token: tgToken,
|
|
179
|
-
allowedChatIds: tgAllow,
|
|
180
|
-
log: (message) => process.stderr.write(`${message}\n`),
|
|
181
|
-
})
|
|
182
|
-
: null;
|
|
183
|
-
ctx.telegram = Boolean(telegramBot);
|
|
173
|
+
// Canales de mensajeria (Telegram via long polling) si hay token.
|
|
174
|
+
const channels = telegram
|
|
175
|
+
? startChannels(ctx, { log: (message) => process.stderr.write(`${message}\n`) })
|
|
176
|
+
: { channels: [], enabled: false, stop() {} };
|
|
177
|
+
ctx.telegram = channels.enabled;
|
|
178
|
+
ctx.channels = channels;
|
|
184
179
|
|
|
185
180
|
const routes = [];
|
|
186
181
|
const route = (method, pattern, handler) =>
|
|
@@ -290,7 +285,8 @@ export async function createTamnoraServer({
|
|
|
290
285
|
|
|
291
286
|
const close = async () => {
|
|
292
287
|
approvals.cancelAll();
|
|
293
|
-
|
|
288
|
+
runs.cancelAll();
|
|
289
|
+
channels.stop();
|
|
294
290
|
preview.close();
|
|
295
291
|
procs.close();
|
|
296
292
|
try {
|
|
@@ -141,6 +141,89 @@ test('streams a chat turn over SSE and persists the messages', async () => {
|
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
test('persists the run log and replays it on the stream endpoint', async () => {
|
|
145
|
+
const { instance, base } = await startServer();
|
|
146
|
+
const json = { 'Content-Type': 'application/json' };
|
|
147
|
+
try {
|
|
148
|
+
await (
|
|
149
|
+
await fetch(`${base}/api/chat`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: json,
|
|
152
|
+
body: JSON.stringify({ input: 'hola', model: 'm' }),
|
|
153
|
+
})
|
|
154
|
+
).text();
|
|
155
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
156
|
+
const id = list.sessions[0].id;
|
|
157
|
+
|
|
158
|
+
const stream = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
|
|
159
|
+
assert.match(stream, /event: start/);
|
|
160
|
+
assert.match(stream, /event: text/);
|
|
161
|
+
assert.match(stream, /event: done/);
|
|
162
|
+
assert.match(stream, /event: idle/);
|
|
163
|
+
assert.match(stream, /^id: \d+$/m);
|
|
164
|
+
} finally {
|
|
165
|
+
await instance.close();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('keeps a run alive after the client disconnects and can stop it', async () => {
|
|
170
|
+
let release;
|
|
171
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
172
|
+
const slowProvider = {
|
|
173
|
+
id: 'slow',
|
|
174
|
+
async streamChat(_config, { onEvent, signal }) {
|
|
175
|
+
onEvent?.({ type: 'text', delta: '…', full: '…' });
|
|
176
|
+
await new Promise((resolve, reject) => {
|
|
177
|
+
if (signal?.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
178
|
+
signal?.addEventListener('abort', () =>
|
|
179
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
180
|
+
);
|
|
181
|
+
gate.then(resolve);
|
|
182
|
+
});
|
|
183
|
+
return { content: 'fin', reasoning: '', toolCalls: [] };
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
|
|
187
|
+
const json = { 'Content-Type': 'application/json' };
|
|
188
|
+
try {
|
|
189
|
+
const created = await (
|
|
190
|
+
await fetch(`${base}/api/sessions`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: json,
|
|
193
|
+
body: JSON.stringify({ model: 'm' }),
|
|
194
|
+
})
|
|
195
|
+
).json();
|
|
196
|
+
const id = created.session.id;
|
|
197
|
+
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: json,
|
|
202
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
203
|
+
signal: controller.signal,
|
|
204
|
+
}).catch(() => null);
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
206
|
+
controller.abort();
|
|
207
|
+
await chat;
|
|
208
|
+
|
|
209
|
+
const during = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
210
|
+
assert.equal(during.running, true);
|
|
211
|
+
|
|
212
|
+
const stopped = await (
|
|
213
|
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST', headers: json })
|
|
214
|
+
).json();
|
|
215
|
+
assert.equal(stopped.stopped, true);
|
|
216
|
+
|
|
217
|
+
release();
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
219
|
+
const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
220
|
+
assert.equal(after.running, false);
|
|
221
|
+
} finally {
|
|
222
|
+
release();
|
|
223
|
+
await instance.close();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
144
227
|
test('manages MCP servers from the console', async () => {
|
|
145
228
|
const { instance, base } = await startServer();
|
|
146
229
|
const json = { 'Content-Type': 'application/json' };
|
|
@@ -898,13 +981,58 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
898
981
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
899
982
|
})
|
|
900
983
|
).text();
|
|
901
|
-
assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read']);
|
|
984
|
+
assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read', 'task']);
|
|
902
985
|
assert.match(captured.system, /modo PLAN/);
|
|
903
986
|
} finally {
|
|
904
987
|
await instance.close();
|
|
905
988
|
}
|
|
906
989
|
});
|
|
907
990
|
|
|
991
|
+
test('the agent can delegate to a subagent with the task tool', async () => {
|
|
992
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-task-'));
|
|
993
|
+
const provider = {
|
|
994
|
+
id: 'multi',
|
|
995
|
+
async streamChat(_config, { messages, onEvent }) {
|
|
996
|
+
const isSub = messages.some((m) => m.role === 'system' && /subagente/i.test(String(m.content)));
|
|
997
|
+
if (isSub) {
|
|
998
|
+
onEvent?.({ type: 'text', delta: 'resumen', full: 'resumen subagente' });
|
|
999
|
+
return { content: 'resumen subagente', reasoning: '', toolCalls: [] };
|
|
1000
|
+
}
|
|
1001
|
+
const done = messages.some((m) => m.role === 'tool' && m.name === 'task');
|
|
1002
|
+
if (done) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
1003
|
+
return {
|
|
1004
|
+
content: '',
|
|
1005
|
+
reasoning: '',
|
|
1006
|
+
toolCalls: [
|
|
1007
|
+
{ id: 't1', function: { name: 'task', arguments: JSON.stringify({ description: 'explorar', prompt: 'leé el proyecto' }) } },
|
|
1008
|
+
],
|
|
1009
|
+
};
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1013
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1014
|
+
try {
|
|
1015
|
+
const stream = await (
|
|
1016
|
+
await fetch(`${base}/api/chat`, {
|
|
1017
|
+
method: 'POST',
|
|
1018
|
+
headers: json,
|
|
1019
|
+
body: JSON.stringify({ input: 'delegá', model: 'm' }),
|
|
1020
|
+
})
|
|
1021
|
+
).text();
|
|
1022
|
+
assert.match(stream, /event: agent_start/);
|
|
1023
|
+
assert.match(stream, /event: agent_end/);
|
|
1024
|
+
|
|
1025
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
1026
|
+
const id = list.sessions[0].id;
|
|
1027
|
+
const agents = await (await fetch(`${base}/api/sessions/${id}/agents`)).json();
|
|
1028
|
+
assert.equal(agents.agents.length, 1);
|
|
1029
|
+
assert.equal(agents.agents[0].status, 'done');
|
|
1030
|
+
assert.match(agents.agents[0].summary, /resumen subagente/);
|
|
1031
|
+
} finally {
|
|
1032
|
+
await instance.close();
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
908
1036
|
test('aggregates the token usage of a session', async () => {
|
|
909
1037
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tokens-'));
|
|
910
1038
|
const provider = {
|