lampson 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/.env.example +29 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/lampson.js +81 -0
- package/chat.syn +799 -0
- package/lamps/example-hello/lamp.json +16 -0
- package/lamps/example-hello/lamp.syn +19 -0
- package/lampson.cmd +4 -0
- package/lampson.ps1 +88 -0
- package/lampson.sh +42 -0
- package/lib/agents.syn +471 -0
- package/lib/git.syn +58 -0
- package/lib/lamps.syn +386 -0
- package/lib/loop.syn +455 -0
- package/lib/lsp.syn +503 -0
- package/lib/mcp.syn +403 -0
- package/lib/permission.syn +154 -0
- package/lib/prompt.syn +75 -0
- package/lib/provider.syn +522 -0
- package/lib/session.syn +111 -0
- package/lib/settings.syn +70 -0
- package/lib/skills.syn +179 -0
- package/lib/tools/bash.syn +105 -0
- package/lib/tools/common.sh +49 -0
- package/lib/tools/common.syn +91 -0
- package/lib/tools/edit.syn +32 -0
- package/lib/tools/find.syn +35 -0
- package/lib/tools/grep.syn +31 -0
- package/lib/tools/img.ps1 +36 -0
- package/lib/tools/img.sh +22 -0
- package/lib/tools/ls.syn +18 -0
- package/lib/tools/memo.syn +148 -0
- package/lib/tools/proc.sh +42 -0
- package/lib/tools/proc.syn +314 -0
- package/lib/tools/process.syn +46 -0
- package/lib/tools/read.syn +25 -0
- package/lib/tools/skill.syn +14 -0
- package/lib/tools/todo.syn +97 -0
- package/lib/tools/write.syn +22 -0
- package/lib/tools.syn +198 -0
- package/lib/trace.syn +116 -0
- package/lib/tree.syn +59 -0
- package/lib/update.syn +57 -0
- package/package.json +40 -0
- package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-400-latin.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin.woff2 +0 -0
- package/public/index.html +1268 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +218 -0
- package/public/vendor/xterm.js +2 -0
- package/skills/debugging/SKILL.md +33 -0
- package/skills/lampson/SKILL.md +117 -0
- package/skills/synsema/SKILL.md +75 -0
- package/web.syn +468 -0
package/lib/loop.syn
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
-- lib/loop.syn — el agent loop (pensar → elegir tool → ejecutar → observar → repetir)
|
|
2
|
+
--
|
|
3
|
+
-- El mismo ciclo que cualquier coding agent, en ~120 líneas: Synsema pone el aislamiento (call_tool)
|
|
4
|
+
-- y la concurrencia en el lenguaje, así que el loop solo orquesta.
|
|
5
|
+
--
|
|
6
|
+
-- run_turn(cfg, messages, opts, on_event) → {messages, text, steps, usage, stopped}
|
|
7
|
+
-- cfg : provider.config()
|
|
8
|
+
-- messages : historial canónico (ver provider.syn); el turno del usuario YA está agregado
|
|
9
|
+
-- opts : {max_steps, budget_tokens, permission_mode, ask_fn, registry, catalog, inbox_fn, tag}
|
|
10
|
+
-- on_event : task(kind, data, tag) — "assistant", "tool_call", "tool_result", "tool_denied", "error", "usage", "compact"
|
|
11
|
+
-- `tag` = opts["tag"]: "" para el agente principal, el id del subagente para sus eventos
|
|
12
|
+
-- inbox_fn : task(step, tag) → {messages: [texto…], stop: bool} | nothing — se consulta ANTES de cada llamada
|
|
13
|
+
-- al modelo. Es el buzón: al padre le trae los avisos de subagentes terminados; al hijo, los
|
|
14
|
+
-- `steer` del padre y la orden de `stop` (que corta el turno devolviendo lo parcial).
|
|
15
|
+
-- Los avisos entran como mensajes `user` nuevos, nunca se muta el contexto pasado (cache intacto).
|
|
16
|
+
--
|
|
17
|
+
-- Garantías (las mismas que documenta llm.md de Synsema):
|
|
18
|
+
-- * Solo se ejecutan tools del allow-list (registry). Un nombre alucinado → error devuelto al modelo.
|
|
19
|
+
-- * Cada tool corre con call_tool: sus `require` ∩ los del programa. No puede exceder su mandato.
|
|
20
|
+
-- * Loop acotado por max_steps y budget_tokens (el guard va en la condición del while).
|
|
21
|
+
-- * Un error de tool NO rompe el loop: se devuelve al modelo como resultado (así aprende y corrige).
|
|
22
|
+
|
|
23
|
+
use "./provider.syn" as provider
|
|
24
|
+
use "./permission.syn" as permission
|
|
25
|
+
use "./mcp.syn" as mcp
|
|
26
|
+
use "./lamps.syn" as lamps
|
|
27
|
+
use "./tools/todo.syn" as todo
|
|
28
|
+
|
|
29
|
+
let MAX_ERRORS_PER_TURN be 8
|
|
30
|
+
-- Conciencia de progreso (2026-08-28, la mejor idea de cada harness — ver notes/*.md):
|
|
31
|
+
-- * SPILL (deepseek): un resultado > SPILL_CAP se guarda entero en .lampson/spill/<call>.txt y el modelo ve
|
|
32
|
+
-- cabeza + cola + "Full result saved to …"; `read` queda exento (ya pagina con offset/limit).
|
|
33
|
+
-- * RECIBOS (hermes): antes de cada llamada, los tool results más allá de los últimos PROTECT_TOKENS se
|
|
34
|
+
-- reemplazan por una línea "[read result pruned: ok · 412 lines/18k chars]" — el modelo conserva QUÉ
|
|
35
|
+
-- hizo y cómo salió, sin re-enviar 30 archivos en cada request.
|
|
36
|
+
-- * REPETICIÓN (deepseek): misma tool + mismos args (JSON canónico) 3 y 5 veces → se ejecuta igual pero
|
|
37
|
+
-- el resultado lleva un recordatorio; 8 → se rechaza (opencode). Nunca cuenta entre turnos.
|
|
38
|
+
-- * PRESUPUESTO (hermes + opencode): al 80% un aviso anexado al ÚLTIMO tool result (cache-safe, sin
|
|
39
|
+
-- mensaje user nuevo); al 95% último paso sin tools con resumen obligatorio.
|
|
40
|
+
-- * TODO (hermes): la lista activa se re-inyecta SOLO tras una compaction.
|
|
41
|
+
export let SPILL_DIR be ".lampson/spill"
|
|
42
|
+
export let SPILL_CAP be 10000
|
|
43
|
+
export let PROTECT_TOKENS be 16000
|
|
44
|
+
let REPEAT_TIERS be [3, 5, 8]
|
|
45
|
+
let REPEAT_HARD be 8
|
|
46
|
+
-- TOPE DE EXPLORACIÓN (propio; ningún harness lo tiene y es lo que faltaba): llamadas de SOLO LECTURA
|
|
47
|
+
-- seguidas sin ninguna acción (edit/write/bash/process/delegate/mcp). Visto 2026-08-28 con deepseek-v4-pro:
|
|
48
|
+
-- 39 read/ls/find seguidos, 0 ediciones, 419k tokens, dos veces, con las reglas de prompt ignoradas.
|
|
49
|
+
-- A la mitad del tope el resultado lleva un aviso; al tope, read/ls/find/grep se rechazan hasta que actúe.
|
|
50
|
+
let READ_ONLY_TOOLS be ["read", "ls", "find", "grep"]
|
|
51
|
+
let ACTION_TOOLS be ["edit", "write", "bash", "process", "delegate"]
|
|
52
|
+
|
|
53
|
+
export task explore_cap()
|
|
54
|
+
require env("LAMPSON_*")
|
|
55
|
+
let e be env("LAMPSON_EXPLORE_CAP", "")
|
|
56
|
+
when e != ""
|
|
57
|
+
give floor(number(e))
|
|
58
|
+
give 16
|
|
59
|
+
|
|
60
|
+
-- {streak, note, refuse}: nuevo streak tras esta llamada y qué hacer con ella
|
|
61
|
+
export task explore_verdict(streak, name, cap)
|
|
62
|
+
when contains(ACTION_TOOLS, name) or starts_with(name, "mcp_")
|
|
63
|
+
give {"streak": 0, "note": "", "refuse": false}
|
|
64
|
+
when not contains(READ_ONLY_TOOLS, name)
|
|
65
|
+
give {"streak": streak, "note": "", "refuse": false}
|
|
66
|
+
let n be streak + 1
|
|
67
|
+
when n >= cap
|
|
68
|
+
give {"streak": n, "note": "", "refuse": true}
|
|
69
|
+
when n == floor(cap / 2)
|
|
70
|
+
give {"streak": n, "note": "\n\n[harness] " + text(n) + " read-only calls in a row without changing anything. You likely know enough: edit/write now, or run the relevant command. After " + text(cap) + " read-only calls in a row, read/ls/find/grep are refused until you act. If the codebase is genuinely too big, delegate ONE focused question to an `explore` sub-agent instead of reading everything yourself.", "refuse": false}
|
|
71
|
+
give {"streak": n, "note": "", "refuse": false}
|
|
72
|
+
|
|
73
|
+
-- JSON canónico (claves ordenadas en profundidad): reordenar propiedades no engaña al detector
|
|
74
|
+
export task canon(v)
|
|
75
|
+
let t be type_of(v)
|
|
76
|
+
when t == "map"
|
|
77
|
+
let parts be []
|
|
78
|
+
each k in sort_by(keys(v), (x) => x)
|
|
79
|
+
set parts to append(parts, json_encode(k) + ":" + canon(v[k]))
|
|
80
|
+
give "{" + join(parts, ",") + "}"
|
|
81
|
+
when t == "list"
|
|
82
|
+
give "[" + join(apply((x) => canon(x), v), ",") + "]"
|
|
83
|
+
give json_encode(v)
|
|
84
|
+
|
|
85
|
+
task one_line_args(args)
|
|
86
|
+
let t be json_encode(args)
|
|
87
|
+
when length(t) > 80
|
|
88
|
+
give slice(t, 0, 80) + "…"
|
|
89
|
+
give t
|
|
90
|
+
|
|
91
|
+
task safe_id(id, step)
|
|
92
|
+
let t be text(id)
|
|
93
|
+
when matches(t, "[A-Za-z0-9_-]{1,80}")
|
|
94
|
+
give t
|
|
95
|
+
give "call-" + text(step)
|
|
96
|
+
|
|
97
|
+
-- cabeza + cola, el resto a disco
|
|
98
|
+
export task spill(name, id, out)
|
|
99
|
+
require file(".lampson")
|
|
100
|
+
require file(".lampson/*")
|
|
101
|
+
when name == "read" or length(out) <= SPILL_CAP
|
|
102
|
+
give out
|
|
103
|
+
let path be SPILL_DIR + "/" + id + ".txt"
|
|
104
|
+
write_file(path, out)
|
|
105
|
+
let half be floor(SPILL_CAP / 2)
|
|
106
|
+
give slice(out, 0, half) + "\n\n[... " + text(length(out) - SPILL_CAP) + " chars omitted. Full result saved to " + path + " — grep it, or read it with offset/limit; do not re-run the command ...]\n\n" + slice(out, length(out) - half, length(out))
|
|
107
|
+
|
|
108
|
+
-- recibo de una línea para un tool result viejo
|
|
109
|
+
export task receipt(m)
|
|
110
|
+
let out be text(m["content"])
|
|
111
|
+
when starts_with(out, "[") and contains(out, " result pruned: ")
|
|
112
|
+
give out
|
|
113
|
+
let status be "ok"
|
|
114
|
+
when starts_with(out, "ERROR") or starts_with(out, "DENIED")
|
|
115
|
+
set status to slice(replace_text(split(out, "\n")[0], "\r", ""), 0, 120)
|
|
116
|
+
otherwise
|
|
117
|
+
let codes be find_all(out, "\\[exit code [0-9]+\\]")
|
|
118
|
+
when length(codes) > 0
|
|
119
|
+
let last be codes[length(codes) - 1]
|
|
120
|
+
set status to slice(last, 1, length(last) - 1)
|
|
121
|
+
let saved be find_all(out, "Full result saved to [^ ]+")
|
|
122
|
+
let where_full be when length(saved) > 0 then " · " + saved[0] otherwise ""
|
|
123
|
+
give "[" + text(m["name"]) + " result pruned: " + status + " · " + text(length(split(out, "\n"))) + " lines/" + text(length(out)) + " chars" + where_full + " — re-run the tool only if you really need the content again]"
|
|
124
|
+
|
|
125
|
+
-- reemplaza por recibos los tool results anteriores a los últimos PROTECT_TOKENS tokens de resultados.
|
|
126
|
+
-- HISTÉRESIS 2× (2026-08-28): podar en cada paso movía la frontera y reescribía UN mensaje viejo por
|
|
127
|
+
-- request — cada reescritura invalida el prompt cache del proveedor desde ese punto (visto en la traza:
|
|
128
|
+
-- hits alternando 44k/11k con DeepSeek, y turnos enteros sin un solo hit). Ahora se poda recién cuando
|
|
129
|
+
-- hay 2×PROTECT_TOKENS de resultados vivos, y de una sola vez: entre podas el historial queda
|
|
130
|
+
-- byte-estable y el prefijo cachea (misma lección que hermes: "anything that mutates past context
|
|
131
|
+
-- invalidates that cache and multiplies cost").
|
|
132
|
+
export task prune_results(msgs)
|
|
133
|
+
let total be 0
|
|
134
|
+
each m in msgs
|
|
135
|
+
when m["role"] == "tool"
|
|
136
|
+
set total to total + floor(length(text(m["content"])) / 4)
|
|
137
|
+
when total <= PROTECT_TOKENS * 2
|
|
138
|
+
give msgs
|
|
139
|
+
let acc be 0
|
|
140
|
+
let cut be length(msgs)
|
|
141
|
+
let i be length(msgs) - 1
|
|
142
|
+
while i >= 0 and acc < PROTECT_TOKENS
|
|
143
|
+
let m be msgs[i]
|
|
144
|
+
when m["role"] == "tool"
|
|
145
|
+
set acc to acc + floor(length(text(m["content"])) / 4)
|
|
146
|
+
set cut to i
|
|
147
|
+
set i to i - 1
|
|
148
|
+
let out be []
|
|
149
|
+
each e in enumerate(msgs)
|
|
150
|
+
let m be e["item"]
|
|
151
|
+
when e["index"] < cut and m["role"] == "tool" and length(text(m["content"])) > 200
|
|
152
|
+
set out to append(out, {"role": "tool", "tool_call_id": m["tool_call_id"], "name": m["name"], "content": receipt(m)})
|
|
153
|
+
otherwise
|
|
154
|
+
set out to append(out, m)
|
|
155
|
+
give out
|
|
156
|
+
|
|
157
|
+
-- anexa un aviso al ÚLTIMO tool result (canal cache-safe, hermes); ok=false si no hay ninguno
|
|
158
|
+
task append_to_last_tool(msgs, note)
|
|
159
|
+
let i be length(msgs) - 1
|
|
160
|
+
while i >= 0
|
|
161
|
+
when msgs[i]["role"] == "tool"
|
|
162
|
+
let m be msgs[i]
|
|
163
|
+
let out be slice(msgs, 0, i) + [{"role": "tool", "tool_call_id": m["tool_call_id"], "name": m["name"], "content": text(m["content"]) + "\n\n" + note}] + slice(msgs, i + 1, length(msgs))
|
|
164
|
+
give {"ok": true, "msgs": out}
|
|
165
|
+
set i to i - 1
|
|
166
|
+
give {"ok": false, "msgs": msgs}
|
|
167
|
+
|
|
168
|
+
task repeat_note(tc, n)
|
|
169
|
+
when n < REPEAT_TIERS[1]
|
|
170
|
+
give "\n\n[harness] You are repeating the exact same tool call with identical arguments (" + text(n) + " times in a row). Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."
|
|
171
|
+
give "\n\n[harness] The repeated calls are not making progress (" + tc["name"] + ", " + text(n) + " consecutive calls with the same arguments). Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."
|
|
172
|
+
|
|
173
|
+
task emit(on_event, kind, data, tag)
|
|
174
|
+
when on_event != nothing
|
|
175
|
+
call(on_event, {"kind": kind, "data": data, "tag": tag})
|
|
176
|
+
|
|
177
|
+
-- Imágenes adjuntas (base64 en m["images"]) fuera del texto: para contar tokens y para el resumen
|
|
178
|
+
-- de compaction se reemplazan por una marca; cada imagen se estima en ~1000 tokens.
|
|
179
|
+
task without_images(messages)
|
|
180
|
+
let out be []
|
|
181
|
+
let n_images be 0
|
|
182
|
+
each m in messages
|
|
183
|
+
when contains(m, "images")
|
|
184
|
+
set n_images to n_images + length(m["images"])
|
|
185
|
+
set out to append(out, {"role": m["role"], "content": m["content"] + "\n[" + text(length(m["images"])) + " imagen(es) adjunta(s)]"})
|
|
186
|
+
otherwise
|
|
187
|
+
set out to append(out, m)
|
|
188
|
+
give {"messages": out, "images": n_images}
|
|
189
|
+
|
|
190
|
+
-- Estimación barata de tokens (4 chars ≈ 1 token), suficiente para decidir compaction.
|
|
191
|
+
export task estimate_tokens(messages)
|
|
192
|
+
let w be without_images(messages)
|
|
193
|
+
give floor(length(json_encode(w["messages"])) / 4) + w["images"] * 1000
|
|
194
|
+
|
|
195
|
+
-- Compaction del contexto:
|
|
196
|
+
-- resume todo menos el system y los últimos `keep_last` mensajes en un único mensaje user.
|
|
197
|
+
export task compact(cfg, messages, keep_last)
|
|
198
|
+
require net
|
|
199
|
+
require time
|
|
200
|
+
let n be length(messages)
|
|
201
|
+
when n <= keep_last + 2
|
|
202
|
+
give messages
|
|
203
|
+
let system be []
|
|
204
|
+
let rest be []
|
|
205
|
+
each m in messages
|
|
206
|
+
when m["role"] == "system"
|
|
207
|
+
set system to append(system, m)
|
|
208
|
+
otherwise
|
|
209
|
+
set rest to append(rest, m)
|
|
210
|
+
let cut be length(rest) - keep_last
|
|
211
|
+
-- no cortar entre un assistant con tool_calls y sus tool results
|
|
212
|
+
let moving be true
|
|
213
|
+
while moving
|
|
214
|
+
when cut >= length(rest)
|
|
215
|
+
set moving to false
|
|
216
|
+
otherwise when rest[cut]["role"] == "tool"
|
|
217
|
+
set cut to cut + 1
|
|
218
|
+
otherwise
|
|
219
|
+
set moving to false
|
|
220
|
+
let old be slice(rest, 0, cut)
|
|
221
|
+
let recent be slice(rest, cut, length(rest))
|
|
222
|
+
let transcript be json_encode(without_images(old)["messages"])
|
|
223
|
+
let req be [{"role": "system", "content": "You summarize agent conversations for context compaction."},
|
|
224
|
+
{"role": "user", "content": "Summarize this conversation between a user and a coding agent so the agent can continue seamlessly. Keep: the user's goals and constraints, decisions made, files touched (with what changed), current state, pending work, and any errors seen. Be concise but complete. Output plain text.\n\n" + truncate_text(transcript, 120000)}]
|
|
225
|
+
let r be provider.chat_retry(cfg, req, [], 2)
|
|
226
|
+
when r["error"] != nothing
|
|
227
|
+
give messages
|
|
228
|
+
let summary be {"role": "user", "content": "[Context compacted — summary of the earlier conversation]\n" + r["text"] + "\n[End of summary. The conversation continues below.]"}
|
|
229
|
+
give system + [summary] + recent
|
|
230
|
+
|
|
231
|
+
task truncate_text(s, max)
|
|
232
|
+
when length(s) > max
|
|
233
|
+
give slice(s, 0, max) + "\n[...truncated...]"
|
|
234
|
+
give s
|
|
235
|
+
|
|
236
|
+
-- Algunos modelos (MiniMax, Qwen…) sin catálogo emiten la tool call como TEXTO en su formato nativo
|
|
237
|
+
-- ("<tool_call>…", "]<]minimax[>[…"). No es una respuesta: se reemplaza por una nota.
|
|
238
|
+
task strip_raw_tool_calls(t)
|
|
239
|
+
when t == nothing
|
|
240
|
+
give ""
|
|
241
|
+
let markers be ["<tool_call>", "]<]", "minimax[>[", "<invoke name=", "<function_calls>", "<|tool_call|>"]
|
|
242
|
+
let cut be length(t)
|
|
243
|
+
each m in markers
|
|
244
|
+
let parts be split(t, m)
|
|
245
|
+
when length(parts) > 1
|
|
246
|
+
when length(parts[0]) < cut
|
|
247
|
+
set cut to length(parts[0])
|
|
248
|
+
when cut == length(t)
|
|
249
|
+
give t
|
|
250
|
+
let head be trim(slice(t, 0, cut))
|
|
251
|
+
let note be "(el modelo intentó llamar una tool más pero el harness no lo permitió en este paso)"
|
|
252
|
+
give when head == "" then note otherwise head + "\n" + note
|
|
253
|
+
|
|
254
|
+
task tool_result_msg(tc, content)
|
|
255
|
+
give {"role": "tool", "tool_call_id": tc["id"], "name": tc["name"], "content": content}
|
|
256
|
+
|
|
257
|
+
-- Ejecuta UNA tool call: permisos → allow-list → call_tool → resultado como texto.
|
|
258
|
+
task execute(tc, opts, on_event)
|
|
259
|
+
let tag be opts["tag"]
|
|
260
|
+
let name be tc["name"]
|
|
261
|
+
let args be tc["args"]
|
|
262
|
+
let registry be opts["registry"]
|
|
263
|
+
when not contains(registry, name)
|
|
264
|
+
emit(on_event, "tool_denied", {"call": tc, "reason": "unknown tool"}, tag)
|
|
265
|
+
give "ERROR: unknown tool '" + name + "'. Available: " + join(keys(registry), ", ")
|
|
266
|
+
when contains(args, "_parse_error")
|
|
267
|
+
give "ERROR: could not parse tool arguments as JSON: " + text(args["_parse_error"])
|
|
268
|
+
let verdict be permission.evaluate(name, args, opts["permission_mode"])
|
|
269
|
+
when verdict["decision"] == "deny"
|
|
270
|
+
emit(on_event, "tool_denied", {"call": tc, "reason": verdict["reason"]}, tag)
|
|
271
|
+
give "DENIED by policy (" + verdict["reason"] + "). Do not retry this exact action; explain or propose an alternative."
|
|
272
|
+
when verdict["decision"] == "ask"
|
|
273
|
+
let approved be false
|
|
274
|
+
when opts["ask_fn"] != nothing
|
|
275
|
+
set approved to call(opts["ask_fn"], {"name": name, "args": args, "why": verdict["reason"]})
|
|
276
|
+
when not approved
|
|
277
|
+
emit(on_event, "tool_denied", {"call": tc, "reason": "user declined (" + verdict["reason"] + ")"}, tag)
|
|
278
|
+
give "DENIED by the user (" + verdict["reason"] + "). Do not retry this exact action; ask the user or propose an alternative."
|
|
279
|
+
try
|
|
280
|
+
-- tools MCP: no son tasks Synsema (args libres); el registry las marca con "mcp"
|
|
281
|
+
when registry[name] == "mcp"
|
|
282
|
+
give text(mcp.call(name, args))
|
|
283
|
+
-- tools de lámparas: un proceso hijo por llamada (lib/lamps.syn)
|
|
284
|
+
when registry[name] == "lamp"
|
|
285
|
+
give text(lamps.call(name, args))
|
|
286
|
+
let result be call_tool(registry[name], args)
|
|
287
|
+
give text(result)
|
|
288
|
+
recover err
|
|
289
|
+
give "ERROR: " + err
|
|
290
|
+
|
|
291
|
+
export task run_turn(cfg, messages, opts, on_event)
|
|
292
|
+
require net
|
|
293
|
+
require time
|
|
294
|
+
require env("LAMPSON_*")
|
|
295
|
+
require file(".lampson")
|
|
296
|
+
require file(".lampson/*")
|
|
297
|
+
let msgs be messages
|
|
298
|
+
let steps be 0
|
|
299
|
+
let spent be 0
|
|
300
|
+
let errors be 0
|
|
301
|
+
let usage be {"input": 0, "output": 0, "cached": 0}
|
|
302
|
+
let final_text be ""
|
|
303
|
+
let stopped be "done"
|
|
304
|
+
let last_sig be ""
|
|
305
|
+
let repeats be 0
|
|
306
|
+
let nudged be false
|
|
307
|
+
let explore_streak be 0
|
|
308
|
+
let cap be explore_cap()
|
|
309
|
+
-- lecturas repetidas en el mismo turno (visto 2026-08-28: globals.css y page.tsx leídos 3 veces cada uno):
|
|
310
|
+
-- si el resultado es idéntico al anterior, el modelo recibe un recibo corto — el contenido ya está en su contexto
|
|
311
|
+
let seen_reads be {}
|
|
312
|
+
-- y un tope TOTAL por turno de llamadas de solo lectura (el de racha se reinicia con cualquier acción)
|
|
313
|
+
let reads_total be 0
|
|
314
|
+
let turn_cap be cap * 2
|
|
315
|
+
let catalog be opts["catalog"]
|
|
316
|
+
let max_steps be opts["max_steps"]
|
|
317
|
+
let budget be opts["budget_tokens"]
|
|
318
|
+
let tag be opts["tag"]
|
|
319
|
+
-- compaction preventiva
|
|
320
|
+
when estimate_tokens(msgs) > opts["compact_at_tokens"]
|
|
321
|
+
emit(on_event, "compact", {"before": estimate_tokens(msgs)}, tag)
|
|
322
|
+
set msgs to compact(cfg, msgs, 6)
|
|
323
|
+
-- la lista de tareas activa sobrevive a la compaction (solo lo pendiente)
|
|
324
|
+
let td be todo.active_text()
|
|
325
|
+
when td != ""
|
|
326
|
+
set msgs to append(msgs, {"role": "user", "content": td})
|
|
327
|
+
while steps < max_steps and spent <= budget
|
|
328
|
+
set steps to steps + 1
|
|
329
|
+
-- recibos: los tool results viejos dejan de viajar completos en cada request
|
|
330
|
+
set msgs to prune_results(msgs)
|
|
331
|
+
-- buzón: avisos de subagentes (padre) / steer y stop (hijo)
|
|
332
|
+
when opts["inbox_fn"] != nothing
|
|
333
|
+
let inb be call(opts["inbox_fn"], {"step": steps, "tag": tag})
|
|
334
|
+
when inb != nothing
|
|
335
|
+
each m in inb["messages"]
|
|
336
|
+
emit(on_event, "inbox", m, tag)
|
|
337
|
+
set msgs to append(msgs, {"role": "user", "content": m})
|
|
338
|
+
when inb["stop"]
|
|
339
|
+
emit(on_event, "error", "stopped by the parent agent", tag)
|
|
340
|
+
give {"messages": msgs, "text": final_text, "steps": steps, "usage": usage, "stopped": "stopped"}
|
|
341
|
+
-- último paso: sin tools + instrucción explícita → respuesta final (truco de llm.md: catálogo vacío)
|
|
342
|
+
-- presupuesto de tokens (visto 2026-08-28: 16 pasos leyendo 30 archivos, 420k tokens, 0 ediciones):
|
|
343
|
+
-- al 80% un aviso anexado al último tool result (una sola vez, cache-safe); al 95% respuesta final sin tools
|
|
344
|
+
when spent >= budget * 0.8 and not nudged
|
|
345
|
+
let noted be append_to_last_tool(msgs, "[SYSTEM NOTICE — token budget nearly exhausted: " + text(spent) + " of " + text(floor(budget)) + "] Stop new discovery/verification work now. Produce the required deliverable (the edits, the answer, the summary) from the state you already have, completing only mandatory writes.")
|
|
346
|
+
when noted["ok"]
|
|
347
|
+
set nudged to true
|
|
348
|
+
set msgs to noted["msgs"]
|
|
349
|
+
emit(on_event, "inbox", "presupuesto de tokens al 80% (" + text(spent) + "/" + text(floor(budget)) + "): el harness le pidió al modelo que cierre", tag)
|
|
350
|
+
let over_budget be spent >= budget * 0.95
|
|
351
|
+
let last_step be steps == max_steps or over_budget
|
|
352
|
+
let cat be when last_step then [] otherwise catalog
|
|
353
|
+
when last_step
|
|
354
|
+
let why be when over_budget then "Token budget for this turn is exhausted (" + text(spent) + " of " + text(floor(budget)) + " tokens)" otherwise "Step limit reached for this turn (" + text(max_steps) + " tool calls)"
|
|
355
|
+
set msgs to append(msgs, {"role": "user", "content": "[harness] " + why + ". Do NOT call tools now. Reply with a short status: what you completed, what is verified, and exactly what remains to do next. The user can say 'continue' to resume."})
|
|
356
|
+
let r be provider.chat_retry(cfg, msgs, cat, 3)
|
|
357
|
+
when r["error"] != nothing
|
|
358
|
+
emit(on_event, "error", r["error"], tag)
|
|
359
|
+
set stopped to "error: " + r["error"]
|
|
360
|
+
set final_text to ""
|
|
361
|
+
give {"messages": msgs, "text": final_text, "steps": steps, "usage": usage, "stopped": stopped}
|
|
362
|
+
let rc be when contains(r["usage"], "cached") then r["usage"]["cached"] otherwise 0
|
|
363
|
+
set usage to {"input": usage["input"] + r["usage"]["input"], "output": usage["output"] + r["usage"]["output"], "cached": usage["cached"] + rc}
|
|
364
|
+
-- el presupuesto mide COSTO: los tokens servidos desde caché de prompt pesan 10%
|
|
365
|
+
set spent to spent + provider.cost_tokens(r["usage"])
|
|
366
|
+
emit(on_event, "usage", {"step": steps, "usage": r["usage"], "total": usage}, tag)
|
|
367
|
+
let clean_text be strip_raw_tool_calls(r["text"])
|
|
368
|
+
let assistant be {"role": "assistant", "content": clean_text, "tool_calls": r["tool_calls"]}
|
|
369
|
+
set msgs to append(msgs, assistant)
|
|
370
|
+
when clean_text != ""
|
|
371
|
+
emit(on_event, "assistant", clean_text, tag)
|
|
372
|
+
when length(r["tool_calls"]) == 0
|
|
373
|
+
set final_text to clean_text
|
|
374
|
+
give {"messages": msgs, "text": final_text, "steps": steps, "usage": usage, "stopped": when over_budget then "budget" otherwise (when last_step then "max_steps" otherwise "done")}
|
|
375
|
+
each tc in r["tool_calls"]
|
|
376
|
+
emit(on_event, "tool_call", tc, tag)
|
|
377
|
+
-- repetición: misma tool + mismos args (JSON canónico); 3 y 5 → recordatorio en el resultado; 8 → rechazo
|
|
378
|
+
let sig be tc["name"] + " " + canon(tc["args"])
|
|
379
|
+
set repeats to when sig == last_sig then repeats + 1 otherwise 1
|
|
380
|
+
set last_sig to sig
|
|
381
|
+
let out be ""
|
|
382
|
+
let ex be explore_verdict(explore_streak, tc["name"], cap)
|
|
383
|
+
set explore_streak to ex["streak"]
|
|
384
|
+
when repeats >= REPEAT_HARD
|
|
385
|
+
set out to "ERROR: you already called " + tc["name"] + " with these exact arguments " + text(repeats) + " times in a row. The result will not change; try a different approach or report the problem."
|
|
386
|
+
emit(on_event, "tool_denied", {"call": tc, "reason": "repeated call (" + text(repeats) + "x)"}, tag)
|
|
387
|
+
otherwise when ex["refuse"]
|
|
388
|
+
set out to "ERROR: exploration cap reached — " + text(explore_streak) + " read-only calls in a row (read/ls/find/grep) without a single change. Reading more will not help. Do one of: (1) edit/write the files you already read; (2) run a command (bash/process) that moves the task; (3) delegate ONE focused question to an `explore` sub-agent; (4) tell the user what you need. read/ls/find/grep are refused until you do."
|
|
389
|
+
emit(on_event, "tool_denied", {"call": tc, "reason": "exploration cap (" + text(explore_streak) + " read-only calls without acting)"}, tag)
|
|
390
|
+
otherwise when contains(READ_ONLY_TOOLS, tc["name"]) and reads_total >= turn_cap
|
|
391
|
+
set out to "ERROR: this turn already made " + text(reads_total) + " read-only calls (limit " + text(turn_cap) + "). Everything you read is in your context. Act on it now (edit/write/run), delegate ONE focused question to an `explore` sub-agent, or report to the user."
|
|
392
|
+
emit(on_event, "tool_denied", {"call": tc, "reason": "read-only calls per turn (" + text(reads_total) + ")"}, tag)
|
|
393
|
+
otherwise
|
|
394
|
+
set out to spill(tc["name"], safe_id(tc["id"], steps), execute(tc, opts, on_event))
|
|
395
|
+
when contains(READ_ONLY_TOOLS, tc["name"])
|
|
396
|
+
set reads_total to reads_total + 1
|
|
397
|
+
let rk be tc["name"] + " " + canon(tc["args"])
|
|
398
|
+
let h be decode(sha256(out), "hex")
|
|
399
|
+
when contains(seen_reads, rk) and seen_reads[rk]["hash"] == h and not starts_with(out, "ERROR")
|
|
400
|
+
set out to "[unchanged: identical to your " + tc["name"] + " at step " + text(seen_reads[rk]["step"]) + " this turn — the content is already in your context above; do not re-read files you already have]"
|
|
401
|
+
emit(on_event, "inbox", "lectura repetida (" + tc["name"] + " " + one_line_args(tc["args"]) + "): recibo en vez de re-enviar el contenido", tag)
|
|
402
|
+
otherwise
|
|
403
|
+
set seen_reads[rk] to {"hash": h, "step": steps}
|
|
404
|
+
when contains(REPEAT_TIERS, repeats)
|
|
405
|
+
set out to out + repeat_note(tc, repeats)
|
|
406
|
+
when ex["note"] != ""
|
|
407
|
+
set out to out + ex["note"]
|
|
408
|
+
emit(on_event, "inbox", "tope de exploración a la mitad (" + text(explore_streak) + "/" + text(cap) + " lecturas seguidas sin actuar): aviso al modelo", tag)
|
|
409
|
+
when starts_with(out, "ERROR") or starts_with(out, "DENIED")
|
|
410
|
+
set errors to errors + 1
|
|
411
|
+
emit(on_event, "tool_result", {"call": tc, "output": out}, tag)
|
|
412
|
+
set msgs to append(msgs, tool_result_msg(tc, out))
|
|
413
|
+
when errors >= MAX_ERRORS_PER_TURN
|
|
414
|
+
set msgs to append(msgs, {"role": "user", "content": "[harness] Too many tool errors this turn. Stop calling tools and report what happened."})
|
|
415
|
+
let fin be provider.chat_retry(cfg, msgs, [], 2)
|
|
416
|
+
set msgs to append(msgs, {"role": "assistant", "content": fin["text"], "tool_calls": []})
|
|
417
|
+
emit(on_event, "assistant", fin["text"], tag)
|
|
418
|
+
give {"messages": msgs, "text": fin["text"], "steps": steps, "usage": usage, "stopped": "too_many_errors"}
|
|
419
|
+
set stopped to when spent > budget then "budget" otherwise "max_steps"
|
|
420
|
+
give {"messages": msgs, "text": final_text, "steps": steps, "usage": usage, "stopped": stopped}
|
|
421
|
+
|
|
422
|
+
-- Opciones por defecto (leen env: LAMPSON_MAX_STEPS, LAMPSON_BUDGET_TOKENS, LAMPSON_PERMISSION, LAMPSON_COMPACT_AT)
|
|
423
|
+
export task default_opts(registry, catalog, ask_fn)
|
|
424
|
+
require env("LAMPSON_*")
|
|
425
|
+
give {
|
|
426
|
+
"max_steps": floor(number(env("LAMPSON_MAX_STEPS", "60"))),
|
|
427
|
+
"budget_tokens": number(env("LAMPSON_BUDGET_TOKENS", "400000")),
|
|
428
|
+
"permission_mode": lower(env("LAMPSON_PERMISSION", "ask")),
|
|
429
|
+
"compact_at_tokens": number(env("LAMPSON_COMPACT_AT", "80000")),
|
|
430
|
+
"ask_fn": ask_fn,
|
|
431
|
+
"registry": registry,
|
|
432
|
+
"catalog": catalog,
|
|
433
|
+
"inbox_fn": nothing,
|
|
434
|
+
"tag": ""
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
-- copia de opts con una clave cambiada (los maps son valores: no hay mutación compartida)
|
|
438
|
+
task with_key(opts, key, value)
|
|
439
|
+
let out be {}
|
|
440
|
+
each k in keys(opts)
|
|
441
|
+
set out[k] to opts[k]
|
|
442
|
+
set out[key] to value
|
|
443
|
+
give out
|
|
444
|
+
|
|
445
|
+
export task with_mode(opts, mode)
|
|
446
|
+
give with_key(opts, "permission_mode", mode)
|
|
447
|
+
|
|
448
|
+
export task with_steps(opts, n)
|
|
449
|
+
give with_key(opts, "max_steps", floor(n))
|
|
450
|
+
|
|
451
|
+
export task with_inbox(opts, inbox_fn, tag)
|
|
452
|
+
give with_key(with_key(opts, "inbox_fn", inbox_fn), "tag", tag)
|
|
453
|
+
|
|
454
|
+
export task with_ask(opts, ask_fn)
|
|
455
|
+
give with_key(opts, "ask_fn", ask_fn)
|