@danieltmn/tamnora 0.1.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 +48 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/SECURITY.md +31 -0
- package/harness/packages/cli/bin/tamnora.mjs +11 -0
- package/harness/packages/cli/package.json +10 -0
- package/harness/packages/cli/src/cli.js +90 -0
- package/harness/packages/cli/src/commands/autostart.js +121 -0
- package/harness/packages/cli/src/commands/bridge.js +274 -0
- package/harness/packages/cli/src/commands/doctor.js +73 -0
- package/harness/packages/cli/src/commands/eval.js +183 -0
- package/harness/packages/cli/src/commands/init.js +31 -0
- package/harness/packages/cli/src/commands/mcp.js +101 -0
- package/harness/packages/cli/src/commands/repl.js +172 -0
- package/harness/packages/cli/src/commands/run.js +165 -0
- package/harness/packages/cli/src/commands/serve.js +1 -0
- package/harness/packages/cli/src/commands/server.js +345 -0
- package/harness/packages/cli/src/commands/tui.js +110 -0
- package/harness/packages/cli/src/commands/update.js +76 -0
- package/harness/packages/cli/src/commands/user.js +135 -0
- package/harness/packages/cli/src/lib/core.js +2 -0
- package/harness/packages/cli/src/lib/daemon.js +153 -0
- package/harness/packages/cli/src/lib/qr.js +203 -0
- package/harness/packages/cli/src/lib/runtime.js +110 -0
- package/harness/packages/cli/src/lib/tunnel.js +113 -0
- package/harness/packages/cli/src/lib/util.js +24 -0
- package/harness/packages/cli/src/tui/app.js +302 -0
- package/harness/packages/cli/src/tui/keys.js +78 -0
- package/harness/packages/cli/src/tui/layout.js +130 -0
- package/harness/packages/cli/src/tui/screen.js +13 -0
- package/harness/packages/cli/src/ui/color.js +15 -0
- package/harness/packages/cli/test/release.test.js +34 -0
- package/harness/packages/cli/test/tui.test.js +99 -0
- package/harness/packages/cli/test/tunnel.test.js +20 -0
- package/harness/packages/core/package.json +14 -0
- package/harness/packages/core/src/agent/context.js +66 -0
- package/harness/packages/core/src/agent/modes.js +26 -0
- package/harness/packages/core/src/agent/runAgent.js +257 -0
- package/harness/packages/core/src/config/app.js +49 -0
- package/harness/packages/core/src/config/home.js +98 -0
- package/harness/packages/core/src/config/identity.js +41 -0
- package/harness/packages/core/src/config/index.js +69 -0
- package/harness/packages/core/src/config/providers.js +24 -0
- package/harness/packages/core/src/env.js +19 -0
- package/harness/packages/core/src/eval/dataset.js +47 -0
- package/harness/packages/core/src/eval/judge.js +39 -0
- package/harness/packages/core/src/eval/report.js +48 -0
- package/harness/packages/core/src/eval/runTask.js +71 -0
- package/harness/packages/core/src/eval/runner.js +150 -0
- package/harness/packages/core/src/eval/scorers.js +217 -0
- package/harness/packages/core/src/eval/task.js +59 -0
- package/harness/packages/core/src/index.js +72 -0
- package/harness/packages/core/src/mcp/common.js +82 -0
- package/harness/packages/core/src/mcp/http.js +149 -0
- package/harness/packages/core/src/mcp/index.js +167 -0
- package/harness/packages/core/src/mcp/stdio.js +218 -0
- package/harness/packages/core/src/providers/index.js +119 -0
- package/harness/packages/core/src/providers/ollama.js +76 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
- package/harness/packages/core/src/providers/openrouter.js +57 -0
- package/harness/packages/core/src/providers/presets.js +19 -0
- package/harness/packages/core/src/providers/resilience.js +74 -0
- package/harness/packages/core/src/providers/stream.js +200 -0
- package/harness/packages/core/src/providers/utils.js +66 -0
- package/harness/packages/core/src/storage/sqlite.js +453 -0
- package/harness/packages/core/src/tools/diff.js +90 -0
- package/harness/packages/core/src/tools/executors.js +277 -0
- package/harness/packages/core/src/tools/glob.js +67 -0
- package/harness/packages/core/src/tools/index.js +28 -0
- package/harness/packages/core/src/tools/paths.js +29 -0
- package/harness/packages/core/src/tools/permissions.js +69 -0
- package/harness/packages/core/src/tools/registry.js +44 -0
- package/harness/packages/core/src/tools/schema.js +177 -0
- package/harness/packages/core/test/agent.test.js +237 -0
- package/harness/packages/core/test/config.test.js +33 -0
- package/harness/packages/core/test/context.test.js +44 -0
- package/harness/packages/core/test/diff.test.js +20 -0
- package/harness/packages/core/test/eval.test.js +144 -0
- package/harness/packages/core/test/git-tools.test.js +44 -0
- package/harness/packages/core/test/home.test.js +62 -0
- package/harness/packages/core/test/mcp.test.js +158 -0
- package/harness/packages/core/test/providers.test.js +80 -0
- package/harness/packages/core/test/resilience.test.js +61 -0
- package/harness/packages/core/test/storage.test.js +55 -0
- package/harness/packages/core/test/stream.test.js +91 -0
- package/harness/packages/core/test/tools.test.js +96 -0
- package/harness/packages/server/bin/tamnora-server.mjs +11 -0
- package/harness/packages/server/package.json +10 -0
- package/harness/packages/server/public/app.js +2188 -0
- package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
- package/harness/packages/server/public/index.html +368 -0
- package/harness/packages/server/public/landing/index.html +523 -0
- package/harness/packages/server/public/landing/landing.js +89 -0
- package/harness/packages/server/public/markdown.js +174 -0
- package/harness/packages/server/public/styles.css +923 -0
- package/harness/packages/server/src/auth.js +146 -0
- package/harness/packages/server/src/cli.js +61 -0
- package/harness/packages/server/src/index.js +1 -0
- package/harness/packages/server/src/lib/approvals.js +51 -0
- package/harness/packages/server/src/lib/core.js +2 -0
- package/harness/packages/server/src/lib/http.js +74 -0
- package/harness/packages/server/src/lib/modelCache.js +32 -0
- package/harness/packages/server/src/lib/prompt.js +48 -0
- package/harness/packages/server/src/lib/workspace.js +74 -0
- package/harness/packages/server/src/routes/auth.js +56 -0
- package/harness/packages/server/src/routes/chat.js +222 -0
- package/harness/packages/server/src/routes/eval.js +100 -0
- package/harness/packages/server/src/routes/files.js +117 -0
- package/harness/packages/server/src/routes/folders.js +80 -0
- package/harness/packages/server/src/routes/mcp.js +50 -0
- package/harness/packages/server/src/routes/models.js +26 -0
- package/harness/packages/server/src/routes/providers.js +135 -0
- package/harness/packages/server/src/routes/sessions.js +157 -0
- package/harness/packages/server/src/server.js +249 -0
- package/harness/packages/server/src/static.js +67 -0
- package/harness/packages/server/test/markdown.test.js +36 -0
- package/harness/packages/server/test/modelCache.test.js +72 -0
- package/harness/packages/server/test/server.test.js +809 -0
- package/harness/scripts/example-dataset.jsonl +4 -0
- package/harness/scripts/mcp-echo-server.mjs +108 -0
- package/harness/scripts/mock-provider.mjs +134 -0
- package/harness/tamnora.example.json +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { runAgent, parseToolArguments } from '../src/agent/runAgent.js';
|
|
7
|
+
import { createTools } from '../src/tools/index.js';
|
|
8
|
+
|
|
9
|
+
test('agent executes tool calls then returns the final answer', async () => {
|
|
10
|
+
const events = [];
|
|
11
|
+
const provider = {
|
|
12
|
+
id: 'mock',
|
|
13
|
+
async streamChat(_config, { messages }) {
|
|
14
|
+
const toolRounds = messages.filter((m) => m.role === 'tool').length;
|
|
15
|
+
if (toolRounds === 0) {
|
|
16
|
+
return {
|
|
17
|
+
content: 'pensando',
|
|
18
|
+
reasoning: '',
|
|
19
|
+
toolCalls: [
|
|
20
|
+
{ id: 'c1', type: 'function', function: { name: 'calculate', arguments: '{"a":1}' } },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const result = await runAgent({
|
|
29
|
+
provider,
|
|
30
|
+
config: {},
|
|
31
|
+
model: 'mock-model',
|
|
32
|
+
messages: [
|
|
33
|
+
{ role: 'system', content: 's' },
|
|
34
|
+
{ role: 'user', content: 'u' },
|
|
35
|
+
],
|
|
36
|
+
tools: [{ type: 'function', function: { name: 'calculate', parameters: {} } }],
|
|
37
|
+
execute: async (name, args) => {
|
|
38
|
+
assert.equal(name, 'calculate');
|
|
39
|
+
assert.deepEqual(args, { a: 1 });
|
|
40
|
+
return 'result';
|
|
41
|
+
},
|
|
42
|
+
onEvent: (e) => events.push(e.type),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.equal(result.content, 'listo');
|
|
46
|
+
assert.equal(result.rounds, 2);
|
|
47
|
+
assert.equal(result.stopReason, 'end_turn');
|
|
48
|
+
assert.ok(events.includes('tool_call'));
|
|
49
|
+
assert.ok(events.includes('tool_result'));
|
|
50
|
+
assert.equal(result.messages.at(-2).role, 'tool');
|
|
51
|
+
assert.equal(result.messages.at(-1).role, 'assistant');
|
|
52
|
+
assert.equal(result.messages.at(-1).content, 'listo');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('agent stops at maxRounds when the model keeps calling tools', async () => {
|
|
56
|
+
const provider = {
|
|
57
|
+
async streamChat() {
|
|
58
|
+
return {
|
|
59
|
+
content: '',
|
|
60
|
+
reasoning: '',
|
|
61
|
+
toolCalls: [{ id: 'x', type: 'function', function: { name: 'noop', arguments: '{}' } }],
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const result = await runAgent({
|
|
66
|
+
provider,
|
|
67
|
+
config: {},
|
|
68
|
+
model: 'm',
|
|
69
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
70
|
+
execute: async () => 'ok',
|
|
71
|
+
maxRounds: 3,
|
|
72
|
+
});
|
|
73
|
+
assert.equal(result.stopReason, 'max_rounds');
|
|
74
|
+
assert.equal(result.rounds, 3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('parseToolArguments handles objects, malformed JSON and garbage', () => {
|
|
78
|
+
assert.deepEqual(parseToolArguments({ function: { arguments: { a: 1 } } }), { a: 1 });
|
|
79
|
+
assert.deepEqual(parseToolArguments({ function: { arguments: '{"b":2,}' } }), { b: 2 });
|
|
80
|
+
assert.deepEqual(parseToolArguments({ function: { arguments: 'nope' } }), {});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('agent writes a file through the local "write" tool', async () => {
|
|
84
|
+
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-agent-'));
|
|
85
|
+
const { definitions, execute } = createTools({ root, permissions: { write: 'allow' } });
|
|
86
|
+
|
|
87
|
+
let round = 0;
|
|
88
|
+
const provider = {
|
|
89
|
+
async streamChat() {
|
|
90
|
+
round++;
|
|
91
|
+
if (round === 1) {
|
|
92
|
+
return {
|
|
93
|
+
content: '',
|
|
94
|
+
reasoning: '',
|
|
95
|
+
toolCalls: [
|
|
96
|
+
{
|
|
97
|
+
id: 'c1',
|
|
98
|
+
type: 'function',
|
|
99
|
+
function: {
|
|
100
|
+
name: 'write',
|
|
101
|
+
arguments: JSON.stringify({ path: 'out.txt', content: 'hola' }),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = await runAgent({
|
|
112
|
+
provider,
|
|
113
|
+
config: {},
|
|
114
|
+
model: 'm',
|
|
115
|
+
messages: [{ role: 'user', content: 'creá out.txt' }],
|
|
116
|
+
tools: definitions,
|
|
117
|
+
execute,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.equal(result.content, 'listo');
|
|
121
|
+
assert.equal(await readFile(path.join(root, 'out.txt'), 'utf8'), 'hola');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('stall watchdog warns once when the model emits nothing', async () => {
|
|
125
|
+
const events = [];
|
|
126
|
+
const provider = {
|
|
127
|
+
async streamChat() {
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
129
|
+
return { content: 'tarde', reasoning: '', toolCalls: [] };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await runAgent({
|
|
134
|
+
provider,
|
|
135
|
+
config: {},
|
|
136
|
+
model: 'm',
|
|
137
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
138
|
+
onEvent: (e) => events.push(e),
|
|
139
|
+
stallWarnMs: 30,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const stalls = events.filter((e) => e.type === 'stall');
|
|
143
|
+
assert.equal(stalls.length, 1);
|
|
144
|
+
assert.equal(stalls[0].seconds, 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('stall watchdog stays quiet while the model streams', async () => {
|
|
148
|
+
const events = [];
|
|
149
|
+
const provider = {
|
|
150
|
+
async streamChat(_config, { onEvent }) {
|
|
151
|
+
for (let i = 0; i < 6; i++) {
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
153
|
+
onEvent?.({ type: 'text', delta: 'a', full: 'a'.repeat(i + 1) });
|
|
154
|
+
}
|
|
155
|
+
return { content: 'aaaaaa', reasoning: '', toolCalls: [] };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await runAgent({
|
|
160
|
+
provider,
|
|
161
|
+
config: {},
|
|
162
|
+
model: 'm',
|
|
163
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
164
|
+
onEvent: (e) => events.push(e),
|
|
165
|
+
// Margen amplio a proposito: el watchdog compara contra Date.now(), asi que un
|
|
166
|
+
// stallWarnMs pegado al intervalo de 20ms dispara por jitter del event loop.
|
|
167
|
+
stallWarnMs: 100,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.equal(events.filter((e) => e.type === 'stall').length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('stall watchdog is disabled with stallWarnMs 0', async () => {
|
|
174
|
+
const events = [];
|
|
175
|
+
const provider = {
|
|
176
|
+
async streamChat() {
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
178
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await runAgent({
|
|
183
|
+
provider,
|
|
184
|
+
config: {},
|
|
185
|
+
model: 'm',
|
|
186
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
187
|
+
onEvent: (e) => events.push(e),
|
|
188
|
+
stallWarnMs: 0,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.equal(events.filter((e) => e.type === 'stall').length, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('agent aggregates usage and stamps tokens on assistant messages', async () => {
|
|
195
|
+
let round = 0;
|
|
196
|
+
const provider = {
|
|
197
|
+
async streamChat() {
|
|
198
|
+
round++;
|
|
199
|
+
if (round === 1) {
|
|
200
|
+
return {
|
|
201
|
+
content: 'pensando',
|
|
202
|
+
reasoning: '',
|
|
203
|
+
toolCalls: [{ id: 'c1', type: 'function', function: { name: 'noop', arguments: '{}' } }],
|
|
204
|
+
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
content: 'listo',
|
|
209
|
+
reasoning: '',
|
|
210
|
+
toolCalls: [],
|
|
211
|
+
usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = await runAgent({
|
|
217
|
+
provider,
|
|
218
|
+
config: {},
|
|
219
|
+
model: 'm',
|
|
220
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
221
|
+
execute: async () => 'ok',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
assert.deepEqual(result.usage, {
|
|
225
|
+
prompt_tokens: 30,
|
|
226
|
+
completion_tokens: 7,
|
|
227
|
+
total_tokens: 37,
|
|
228
|
+
last_prompt_tokens: 20,
|
|
229
|
+
last_completion_tokens: 5,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const assistants = result.messages.filter((m) => m.role === 'assistant');
|
|
233
|
+
assert.equal(assistants[0].tokens_in, 10);
|
|
234
|
+
assert.equal(assistants[0].tokens_out, 2);
|
|
235
|
+
assert.equal(assistants[1].tokens_in, 20);
|
|
236
|
+
assert.equal(assistants[1].tokens_out, 5);
|
|
237
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { loadConfig, loadInstructions, DEFAULT_CONFIG } from '../src/config/index.js';
|
|
7
|
+
import { resolveProviderRuntime, getProvider } from '../src/providers/index.js';
|
|
8
|
+
|
|
9
|
+
test('loadConfig returns defaults when tamnora.json is absent', async () => {
|
|
10
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-'));
|
|
11
|
+
const config = await loadConfig(dir);
|
|
12
|
+
assert.equal(config.provider, DEFAULT_CONFIG.provider);
|
|
13
|
+
assert.equal(config.maxRounds, DEFAULT_CONFIG.maxRounds);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('loadConfig merges tamnora.json and loadInstructions reads AGENTS.md', async () => {
|
|
17
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-'));
|
|
18
|
+
await writeFile(path.join(dir, 'tamnora.json'), JSON.stringify({ model: 'x', maxRounds: 3 }));
|
|
19
|
+
await writeFile(path.join(dir, 'AGENTS.md'), '# Instrucciones\nHacé esto.');
|
|
20
|
+
const config = await loadConfig(dir);
|
|
21
|
+
assert.equal(config.model, 'x');
|
|
22
|
+
assert.equal(config.maxRounds, 3);
|
|
23
|
+
const instructions = await loadInstructions(dir, config.instructions);
|
|
24
|
+
assert.match(instructions, /Instrucciones/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('provider presets resolve their base URL', () => {
|
|
28
|
+
assert.equal(getProvider('deepseek').defaultBaseUrl, 'https://api.deepseek.com/v1');
|
|
29
|
+
const { provider, config } = resolveProviderRuntime({ providerId: 'deepseek', apiKey: 'k' });
|
|
30
|
+
assert.equal(provider.id, 'deepseek');
|
|
31
|
+
assert.equal(config.baseUrl, 'https://api.deepseek.com/v1');
|
|
32
|
+
assert.equal(config.apiKey, 'k');
|
|
33
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { estimateTokens, trimContext } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
test('estimateTokens approximates by characters', () => {
|
|
6
|
+
const messages = [{ role: 'user', content: 'x'.repeat(400) }];
|
|
7
|
+
assert.equal(estimateTokens(messages), 100);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('trimContext is a no-op under budget', () => {
|
|
11
|
+
const messages = [{ role: 'user', content: 'hola' }];
|
|
12
|
+
const result = trimContext(messages, { maxTokens: 1000 });
|
|
13
|
+
assert.equal(result.messages, messages);
|
|
14
|
+
assert.equal(result.trimmed, 0);
|
|
15
|
+
assert.equal(result.dropped, 0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('trimContext shrinks old tool results first', () => {
|
|
19
|
+
const messages = [
|
|
20
|
+
{ role: 'system', content: 'sys' },
|
|
21
|
+
{ role: 'user', content: 'hola' },
|
|
22
|
+
{ role: 'assistant', content: null, tool_calls: [{ id: '1', function: { name: 'read', arguments: '{}' } }] },
|
|
23
|
+
{ role: 'tool', tool_call_id: '1', name: 'read', content: 'y'.repeat(20000) },
|
|
24
|
+
{ role: 'user', content: 'ultima' },
|
|
25
|
+
];
|
|
26
|
+
const result = trimContext(messages, { maxTokens: 500, keepLast: 1 });
|
|
27
|
+
assert.ok(result.trimmed >= 1);
|
|
28
|
+
assert.ok(estimateTokens(result.messages) < estimateTokens(messages));
|
|
29
|
+
// el historial original no se muta
|
|
30
|
+
assert.equal(messages[3].content.length, 20000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('trimContext drops oldest groups but keeps the system and the tail', () => {
|
|
34
|
+
const messages = [{ role: 'system', content: 's' }];
|
|
35
|
+
for (let i = 0; i < 20; i++) {
|
|
36
|
+
messages.push({ role: 'user', content: `u${i} ${'a'.repeat(400)}` });
|
|
37
|
+
messages.push({ role: 'assistant', content: `a${i} ${'b'.repeat(400)}` });
|
|
38
|
+
}
|
|
39
|
+
const result = trimContext(messages, { maxTokens: 300, keepLast: 4 });
|
|
40
|
+
assert.ok(result.dropped > 0);
|
|
41
|
+
assert.equal(result.messages[0].content, 's');
|
|
42
|
+
assert.ok(result.messages.length <= messages.length);
|
|
43
|
+
assert.ok(result.messages.length > 4);
|
|
44
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { diffLines } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
test('diffLines reports added and removed lines with context', () => {
|
|
6
|
+
const diff = diffLines('a\nb\nc\n', 'a\nB\nc\n');
|
|
7
|
+
assert.match(diff, /^- b$/m);
|
|
8
|
+
assert.match(diff, /^\+ B$/m);
|
|
9
|
+
assert.match(diff, /^ {2}a$/m);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('diffLines returns empty when there are no changes', () => {
|
|
13
|
+
assert.equal(diffLines('x\ny\n', 'x\ny\n'), '');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('diffLines marks a new file as additions', () => {
|
|
17
|
+
const diff = diffLines('', 'l1\nl2\n');
|
|
18
|
+
assert.match(diff, /^\+ l1$/m);
|
|
19
|
+
assert.match(diff, /^\+ l2$/m);
|
|
20
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { normalizeExpect, createTaskWorkspace } from '../src/eval/task.js';
|
|
8
|
+
import { runScorers, validateSchema } from '../src/eval/scorers.js';
|
|
9
|
+
import { runDataset, extractToolCalls } from '../src/eval/runner.js';
|
|
10
|
+
|
|
11
|
+
test('normalizeExpect accepts strings, objects and arrays', () => {
|
|
12
|
+
assert.deepEqual(normalizeExpect('hola'), [{ type: 'contains', value: 'hola' }]);
|
|
13
|
+
assert.deepEqual(normalizeExpect({ equals: 'x' }), [{ type: 'exact', value: 'x' }]);
|
|
14
|
+
assert.deepEqual(normalizeExpect([{ regex: 'a+' }, 'b']), [
|
|
15
|
+
{ type: 'regex', pattern: 'a+' },
|
|
16
|
+
{ type: 'contains', value: 'b' },
|
|
17
|
+
]);
|
|
18
|
+
assert.deepEqual(normalizeExpect(undefined), []);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('validateSchema checks types, required and properties', () => {
|
|
22
|
+
const schema = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
required: ['name'],
|
|
25
|
+
properties: { name: { type: 'string' }, age: { type: 'integer' } },
|
|
26
|
+
};
|
|
27
|
+
assert.equal(validateSchema({ name: 'a', age: 3 }, schema).valid, true);
|
|
28
|
+
assert.equal(validateSchema({ age: 3 }, schema).valid, false);
|
|
29
|
+
assert.equal(validateSchema({ name: 'a', age: 'x' }, schema).valid, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('runScorers evaluates text, tool and file scorers', async () => {
|
|
33
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-score-'));
|
|
34
|
+
await writeFile(path.join(dir, 'out.txt'), 'contenido hola');
|
|
35
|
+
|
|
36
|
+
const task = {
|
|
37
|
+
expect: [
|
|
38
|
+
{ type: 'contains', value: '4' },
|
|
39
|
+
{ type: 'regex', pattern: '\\d+' },
|
|
40
|
+
{ type: 'toolCalled', name: 'write', argsContains: 'out.txt' },
|
|
41
|
+
{ type: 'fileExists', path: 'out.txt' },
|
|
42
|
+
{ type: 'fileContains', path: 'out.txt', value: 'hola' },
|
|
43
|
+
{ type: 'jsonSchema', schema: { type: 'object', required: ['ok'] } },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const scoring = await runScorers(task, {
|
|
48
|
+
output: 'El resultado es 4: {"ok": true}',
|
|
49
|
+
toolCalls: [{ name: 'write', args: { path: 'out.txt' } }],
|
|
50
|
+
workspaceDir: dir,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(scoring.passed, true);
|
|
54
|
+
assert.equal(scoring.score, 1);
|
|
55
|
+
assert.equal(scoring.details.length, 6);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('runScorers fails when a criterion is not met', async () => {
|
|
59
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-score-'));
|
|
60
|
+
const scoring = await runScorers(
|
|
61
|
+
{ expect: { contains: 'gato' } },
|
|
62
|
+
{ output: 'perro', workspaceDir: dir }
|
|
63
|
+
);
|
|
64
|
+
assert.equal(scoring.passed, false);
|
|
65
|
+
assert.equal(scoring.details[0].detail.includes('no contiene'), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('createTaskWorkspace copies the fixture', async () => {
|
|
69
|
+
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-fixture-'));
|
|
70
|
+
await mkdir(path.join(root, 'fixtures', 't1'), { recursive: true });
|
|
71
|
+
await writeFile(path.join(root, 'fixtures', 't1', 'seed.txt'), 'seed');
|
|
72
|
+
const ws = await createTaskWorkspace({ fixture: 'fixtures/t1' }, { rootDir: root });
|
|
73
|
+
const { readFile } = await import('node:fs/promises');
|
|
74
|
+
assert.equal(await readFile(path.join(ws.dir, 'seed.txt'), 'utf8'), 'seed');
|
|
75
|
+
await ws.cleanup();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('extractToolCalls parses tool calls from messages', () => {
|
|
79
|
+
const calls = extractToolCalls([
|
|
80
|
+
{ role: 'assistant', tool_calls: [{ function: { name: 'write', arguments: '{"a":1}' } }] },
|
|
81
|
+
{ role: 'tool', content: 'ok' },
|
|
82
|
+
]);
|
|
83
|
+
assert.deepEqual(calls, [{ name: 'write', args: { a: 1 } }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('runDataset scores a task end-to-end (mock Ollama server)', async () => {
|
|
87
|
+
const server = http.createServer((req, res) => {
|
|
88
|
+
let body = '';
|
|
89
|
+
req.on('data', (c) => (body += c));
|
|
90
|
+
req.on('end', () => {
|
|
91
|
+
const parsed = JSON.parse(body);
|
|
92
|
+
const hasTool = parsed.messages.some((m) => m.role === 'tool');
|
|
93
|
+
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
94
|
+
if (!hasTool) {
|
|
95
|
+
res.write(
|
|
96
|
+
`${JSON.stringify({
|
|
97
|
+
message: {
|
|
98
|
+
content: '',
|
|
99
|
+
tool_calls: [{ function: { name: 'write', arguments: { path: 'x.txt', content: 'hi' } } }],
|
|
100
|
+
},
|
|
101
|
+
done: false,
|
|
102
|
+
})}\n`
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
res.write(`${JSON.stringify({ message: { content: 'listo' }, done: false })}\n`);
|
|
106
|
+
}
|
|
107
|
+
res.write(`${JSON.stringify({ done: true })}\n`);
|
|
108
|
+
res.end();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
112
|
+
const { port } = server.address();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const results = await runDataset({
|
|
116
|
+
tasks: [
|
|
117
|
+
{
|
|
118
|
+
id: 't1',
|
|
119
|
+
input: 'creá x.txt',
|
|
120
|
+
expect: [
|
|
121
|
+
{ type: 'toolCalled', name: 'write' },
|
|
122
|
+
{ type: 'fileContains', path: 'x.txt', value: 'hi' },
|
|
123
|
+
{ type: 'contains', value: 'listo' },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
models: ['mock'],
|
|
128
|
+
providerId: 'ollama',
|
|
129
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
130
|
+
systemPrompt: 'test',
|
|
131
|
+
enabledTools: ['write'],
|
|
132
|
+
yes: true,
|
|
133
|
+
concurrency: 1,
|
|
134
|
+
rootDir: process.cwd(),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.equal(results.length, 1);
|
|
138
|
+
assert.equal(results[0].passed, true);
|
|
139
|
+
assert.equal(results[0].error, null);
|
|
140
|
+
assert.equal(results[0].trace.scorers.length, 3);
|
|
141
|
+
} finally {
|
|
142
|
+
server.close();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { executors } from '../src/tools/index.js';
|
|
8
|
+
|
|
9
|
+
const git_tools = executors.EXECUTORS;
|
|
10
|
+
|
|
11
|
+
function git(cwd, args) {
|
|
12
|
+
return execFileSync('git', args, { cwd, stdio: 'ignore', windowsHide: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('git tools read status/log and commit', async (t) => {
|
|
16
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-git-'));
|
|
17
|
+
try {
|
|
18
|
+
git(cwd, ['init']);
|
|
19
|
+
} catch {
|
|
20
|
+
t.skip('git no disponible');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
git(cwd, ['config', 'user.email', 'test@tamnora.local']);
|
|
24
|
+
git(cwd, ['config', 'user.name', 'Tamnora Test']);
|
|
25
|
+
await writeFile(path.join(cwd, 'a.txt'), 'hola\n');
|
|
26
|
+
|
|
27
|
+
const status = await git_tools.git_status(cwd, {});
|
|
28
|
+
assert.match(status, /a\.txt|\?\?/);
|
|
29
|
+
|
|
30
|
+
const commit = await git_tools.git_commit(cwd, { message: 'primer commit', files: ['a.txt'] });
|
|
31
|
+
assert.match(commit, /primer commit/i);
|
|
32
|
+
|
|
33
|
+
const log = await git_tools.git_log(cwd, { limit: 5 });
|
|
34
|
+
assert.match(log, /primer commit/);
|
|
35
|
+
|
|
36
|
+
// El working tree quedo limpio tras commitear.
|
|
37
|
+
const clean = await git_tools.git_status(cwd, {});
|
|
38
|
+
assert.match(clean, /##/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('git_commit requires a message', async () => {
|
|
42
|
+
const out = await git_tools.git_commit(process.cwd(), {});
|
|
43
|
+
assert.match(out, /falta "message"/i);
|
|
44
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
setHome,
|
|
8
|
+
homeDir,
|
|
9
|
+
homePath,
|
|
10
|
+
databaseFile,
|
|
11
|
+
ensureApp,
|
|
12
|
+
readApp,
|
|
13
|
+
writeApp,
|
|
14
|
+
migrateLegacyDb,
|
|
15
|
+
} from '../src/index.js';
|
|
16
|
+
|
|
17
|
+
const prevHome = process.env.TAMNORA_HOME;
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
setHome(null);
|
|
21
|
+
if (prevHome === undefined) delete process.env.TAMNORA_HOME;
|
|
22
|
+
else process.env.TAMNORA_HOME = prevHome;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('homeDir honors TAMNORA_HOME and the setHome override', async () => {
|
|
26
|
+
const fromEnv = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
27
|
+
process.env.TAMNORA_HOME = fromEnv;
|
|
28
|
+
assert.equal(homeDir(), path.resolve(fromEnv));
|
|
29
|
+
|
|
30
|
+
const override = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
31
|
+
setHome(override);
|
|
32
|
+
assert.equal(homeDir(), path.resolve(override));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('ensureApp creates app.json with a secret and roundtrips', async () => {
|
|
36
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
37
|
+
setHome(dir);
|
|
38
|
+
|
|
39
|
+
const app = ensureApp();
|
|
40
|
+
assert.ok(app.authSecret);
|
|
41
|
+
assert.equal(app.port, 4517);
|
|
42
|
+
|
|
43
|
+
const raw = JSON.parse(await readFile(homePath('app.json'), 'utf8'));
|
|
44
|
+
assert.equal(raw.authSecret, app.authSecret);
|
|
45
|
+
|
|
46
|
+
raw.port = 4599;
|
|
47
|
+
writeApp(raw);
|
|
48
|
+
assert.equal(readApp().port, 4599);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('migrateLegacyDb copies the legacy project database to the global home', async () => {
|
|
52
|
+
const home = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-cwd-'));
|
|
54
|
+
setHome(home);
|
|
55
|
+
|
|
56
|
+
await mkdir(path.join(cwd, '.tamnora'), { recursive: true });
|
|
57
|
+
await writeFile(path.join(cwd, '.tamnora', 'tamnora.db'), 'legacy-db');
|
|
58
|
+
|
|
59
|
+
const target = migrateLegacyDb(cwd);
|
|
60
|
+
assert.equal(target, databaseFile());
|
|
61
|
+
assert.equal(await readFile(target, 'utf8'), 'legacy-db');
|
|
62
|
+
});
|