@danieltmn/tamnora 0.5.1 → 0.6.1
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 +14 -0
- package/README.md +30 -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 +527 -111
- package/harness/packages/server/public/index.html +36 -1
- package/harness/packages/server/public/styles.css +97 -4
- package/harness/packages/server/src/lib/channels/index.js +89 -0
- package/harness/packages/server/src/lib/channels/telegram.js +532 -0
- 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/channels.js +47 -0
- package/harness/packages/server/src/routes/chat.js +166 -77
- package/harness/packages/server/src/routes/sessions.js +17 -0
- package/harness/packages/server/src/server.js +15 -16
- package/harness/packages/server/test/server.test.js +213 -1
- package/harness/packages/server/test/telegram.test.js +94 -0
- package/harness/tamnora.example.json +2 -1
- package/package.json +1 -1
- package/harness/packages/server/src/lib/telegram.js +0 -267
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Configuracion de los canales de mensajeria desde la consola. La config vive
|
|
2
|
+
// en app.json (casa); al guardar se recargan los canales en caliente.
|
|
3
|
+
|
|
4
|
+
import { readApp, writeApp } from '../lib/core.js';
|
|
5
|
+
import { getTelegramBotInfo } from '../lib/channels/telegram.js';
|
|
6
|
+
import { readJson, sendJson } from '../lib/http.js';
|
|
7
|
+
|
|
8
|
+
function sanitizeAllowed(value) {
|
|
9
|
+
if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean);
|
|
10
|
+
return String(value ?? '')
|
|
11
|
+
.split(/[,\s]+/)
|
|
12
|
+
.map((s) => s.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerChannels(route, ctx) {
|
|
17
|
+
route('GET', '/api/channels', ({ res }) => {
|
|
18
|
+
sendJson(res, 200, { telegram: ctx.channels.status() });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
route('PUT', '/api/channels/telegram', async ({ req, res }) => {
|
|
22
|
+
const body = await readJson(req);
|
|
23
|
+
const app = readApp();
|
|
24
|
+
const next = { ...(app.telegram ?? {}) };
|
|
25
|
+
|
|
26
|
+
if ('token' in body) {
|
|
27
|
+
const token = String(body.token ?? '').trim();
|
|
28
|
+
// El cliente puede mandar la mascara (`••••1234`): no pisar el token real.
|
|
29
|
+
if (!token.includes('•')) next.token = token;
|
|
30
|
+
}
|
|
31
|
+
if ('allowedChatIds' in body) next.allowedChatIds = sanitizeAllowed(body.allowedChatIds);
|
|
32
|
+
if ('transcribeModel' in body) next.transcribeModel = String(body.transcribeModel ?? '').trim() || 'whisper-1';
|
|
33
|
+
|
|
34
|
+
app.telegram = next;
|
|
35
|
+
writeApp(app);
|
|
36
|
+
ctx.app = app;
|
|
37
|
+
ctx.channels.load();
|
|
38
|
+
sendJson(res, 200, { telegram: ctx.channels.status() });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
route('POST', '/api/channels/telegram/test', async ({ req, res }) => {
|
|
42
|
+
const body = await readJson(req).catch(() => ({}));
|
|
43
|
+
let token = String(body.token ?? '').trim();
|
|
44
|
+
if (!token || token.includes('•')) token = ctx.channels.readConfig().token ?? '';
|
|
45
|
+
sendJson(res, 200, await getTelegramBotInfo(token));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -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,89 @@ 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
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
partials.
|
|
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 startedAt = Date.now();
|
|
243
|
+
const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
|
|
244
|
+
try {
|
|
245
|
+
const result = await runAgent({
|
|
246
|
+
provider,
|
|
247
|
+
config,
|
|
248
|
+
model,
|
|
249
|
+
messages,
|
|
250
|
+
tools: definitions,
|
|
251
|
+
execute,
|
|
252
|
+
signal: ctx.runs.signal(sessionId),
|
|
253
|
+
maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
|
|
254
|
+
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
255
|
+
contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
|
|
256
|
+
onEvent(event) {
|
|
257
|
+
if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
|
|
258
|
+
else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
|
|
259
|
+
else if (event.type === 'stall') {
|
|
260
|
+
partials.flush();
|
|
261
|
+
ctx.runs.emit(sessionId, 'stall', { seconds: event.seconds });
|
|
262
|
+
} else if (event.type === 'retry') {
|
|
263
|
+
partials.flush();
|
|
264
|
+
ctx.runs.emit(sessionId, 'retry', { attempt: event.attempt, delayMs: event.delayMs, message: event.message });
|
|
265
|
+
} else if (event.type === 'trim') {
|
|
266
|
+
partials.flush();
|
|
267
|
+
ctx.runs.emit(sessionId, 'trim', { trimmed: event.trimmed, dropped: event.dropped, tokens: event.tokens });
|
|
268
|
+
} else if (event.type === 'tool_round') {
|
|
269
|
+
partials.flush();
|
|
270
|
+
ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
|
|
271
|
+
} else if (event.type === 'tool_call') {
|
|
272
|
+
partials.flush();
|
|
273
|
+
ctx.runs.emit(sessionId, 'tool_call', {
|
|
274
|
+
id: event.id,
|
|
275
|
+
name: event.name,
|
|
276
|
+
args: event.args,
|
|
277
|
+
summary: summarizeToolCall(event.name, event.args),
|
|
278
|
+
});
|
|
279
|
+
} else if (event.type === 'tool_result') {
|
|
280
|
+
partials.flush();
|
|
281
|
+
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
282
|
+
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
283
|
+
ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
partials.flush();
|
|
288
|
+
|
|
289
|
+
const newMessages = result.messages
|
|
290
|
+
.slice(prefixLength)
|
|
291
|
+
.map((message) => (message.role === 'assistant' ? { ...message, mode: mode.id } : message));
|
|
292
|
+
ctx.store.addMessages(sessionId, newMessages);
|
|
293
|
+
const current = ctx.store.getSession(sessionId);
|
|
294
|
+
const title =
|
|
295
|
+
current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
|
|
296
|
+
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
297
|
+
const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
|
|
298
|
+
const durationMs = Date.now() - startedAt;
|
|
299
|
+
if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls });
|
|
300
|
+
else
|
|
301
|
+
ctx.runs.emit(sessionId, 'done', {
|
|
302
|
+
sessionId,
|
|
303
|
+
content: result.content,
|
|
304
|
+
rounds: result.rounds,
|
|
305
|
+
stopReason: result.stopReason,
|
|
306
|
+
tokens: result.usage ?? null,
|
|
307
|
+
toolCalls,
|
|
308
|
+
durationMs,
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
partials.flush();
|
|
312
|
+
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
|
|
313
|
+
else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
|
|
314
|
+
} finally {
|
|
315
|
+
unsubscribe();
|
|
316
|
+
sse.close();
|
|
317
|
+
ctx.runs.finish(sessionId);
|
|
318
|
+
}
|
|
319
|
+
})();
|
|
231
320
|
});
|
|
232
321
|
}
|
|
@@ -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 { createChannelsManager } 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';
|
|
@@ -31,6 +32,7 @@ import { registerModels } from './routes/models.js';
|
|
|
31
32
|
import { registerProviders } from './routes/providers.js';
|
|
32
33
|
import { registerMcp } from './routes/mcp.js';
|
|
33
34
|
import { registerSkills } from './routes/skills.js';
|
|
35
|
+
import { registerChannels } from './routes/channels.js';
|
|
34
36
|
import { registerProcs } from './routes/procs.js';
|
|
35
37
|
import { registerPreview } from './routes/preview.js';
|
|
36
38
|
import { registerChat } from './routes/chat.js';
|
|
@@ -71,6 +73,7 @@ export async function createTamnoraServer({
|
|
|
71
73
|
const authEnabled = Boolean(app.user && app.user.password);
|
|
72
74
|
const store = new Store(dbPath ?? databasePath(cwd));
|
|
73
75
|
const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
|
|
76
|
+
const runs = createRunManager({ store });
|
|
74
77
|
const procs = createProcManager({ allow: config.processes?.allow });
|
|
75
78
|
const procsEnabled = config.processes?.enabled !== false;
|
|
76
79
|
const autoApproved = new Set();
|
|
@@ -142,6 +145,7 @@ export async function createTamnoraServer({
|
|
|
142
145
|
authEnabled,
|
|
143
146
|
store,
|
|
144
147
|
approvals,
|
|
148
|
+
runs,
|
|
145
149
|
isAutoApprove,
|
|
146
150
|
setAutoApprove,
|
|
147
151
|
yes,
|
|
@@ -167,20 +171,13 @@ export async function createTamnoraServer({
|
|
|
167
171
|
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
168
172
|
ctx.agentTools = createAgentTools(ctx);
|
|
169
173
|
|
|
170
|
-
//
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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);
|
|
174
|
+
// Canales de mensajeria (Telegram via long polling) si hay token.
|
|
175
|
+
const channels = createChannelsManager(ctx, {
|
|
176
|
+
enabled: telegram,
|
|
177
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
178
|
+
});
|
|
179
|
+
channels.load();
|
|
180
|
+
ctx.channels = channels;
|
|
184
181
|
|
|
185
182
|
const routes = [];
|
|
186
183
|
const route = (method, pattern, handler) =>
|
|
@@ -206,6 +203,7 @@ export async function createTamnoraServer({
|
|
|
206
203
|
registerProviders(route, ctx);
|
|
207
204
|
registerMcp(route, ctx);
|
|
208
205
|
registerSkills(route, ctx);
|
|
206
|
+
registerChannels(route, ctx);
|
|
209
207
|
registerProcs(route, ctx);
|
|
210
208
|
const preview = registerPreview(route, ctx);
|
|
211
209
|
registerChat(route, ctx);
|
|
@@ -290,7 +288,8 @@ export async function createTamnoraServer({
|
|
|
290
288
|
|
|
291
289
|
const close = async () => {
|
|
292
290
|
approvals.cancelAll();
|
|
293
|
-
|
|
291
|
+
runs.cancelAll();
|
|
292
|
+
channels.stop();
|
|
294
293
|
preview.close();
|
|
295
294
|
procs.close();
|
|
296
295
|
try {
|