loom-agent 1.2.37 → 1.3.3
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 +92 -0
- package/README.md +34 -27
- package/package.json +126 -125
- package/src/core/sandbox.js +576 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +90 -24
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/index.js +27 -1
- package/src/tui/App.tsx +84 -4
- package/src/tui/components/Modals.tsx +220 -1
- package/src/tui/store.ts +34 -33
|
@@ -1,167 +1,245 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
|
|
5
|
-
const SESSION_SCHEMA_VERSION =
|
|
6
|
-
|
|
7
|
-
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
schemaVersion:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const SESSION_SCHEMA_VERSION = 3;
|
|
6
|
+
|
|
7
|
+
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
8
|
+
|
|
9
|
+
// Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
|
|
10
|
+
// Layout is claude-code/opencode style: one folder per session inside the
|
|
11
|
+
// PROJECT's .loom/ directory:
|
|
12
|
+
//
|
|
13
|
+
// <project>/.loom/sessions/<id>/session.json metadata
|
|
14
|
+
// <project>/.loom/sessions/<id>/messages.jsonl one message per line
|
|
15
|
+
//
|
|
16
|
+
// Flat legacy files (~/.loom/sessions/<id>.json, schema v1/v2) stay
|
|
17
|
+
// readable; the next saveSession migrates them into the project folder.
|
|
18
|
+
function baseDir() {
|
|
19
|
+
if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
|
|
20
|
+
return path.join(process.cwd(), '.loom', 'sessions');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The pre-v3 global store. Same as baseDir() under LOOM_CONFIG_DIR (tests),
|
|
24
|
+
// the user's old ~/.loom/sessions otherwise.
|
|
25
|
+
function legacyDir() {
|
|
26
|
+
if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
|
|
27
|
+
return path.join(LOOM_DIR, 'sessions');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureDir() {
|
|
31
|
+
const d = baseDir();
|
|
32
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function convId() {
|
|
36
|
+
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Session IDs become folder names — keep them strictly alphanumeric to rule
|
|
40
|
+
// out path traversal via loadSession/deleteSession (e.g. "../../secrets").
|
|
41
|
+
const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
|
|
42
|
+
function isValidSessionId(id) {
|
|
43
|
+
return typeof id === 'string' && ID_RX.test(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
|
|
47
|
+
// guaranteed current shape: { id, createdAt, schemaVersion, messages } with
|
|
48
|
+
// every message having { role, content } while preserving any extra fields.
|
|
49
|
+
// Snake-case tool fields from old saves are aliased to camelCase so a resumed
|
|
50
|
+
// session can always rebuild a valid provider payload (toolCallId is sent as
|
|
51
|
+
// tool_call_id / tool_use_id — losing it 400s on strict providers).
|
|
52
|
+
function normalizeSession(data) {
|
|
53
|
+
if (!data || typeof data !== 'object') return null;
|
|
54
|
+
const raw = Array.isArray(data.messages) ? data.messages : [];
|
|
55
|
+
const messages = raw.map((m) => {
|
|
56
|
+
if (!m || typeof m !== 'object') return { role: 'user', content: '' };
|
|
57
|
+
const { role, content, ...rest } = m;
|
|
58
|
+
const out = { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
|
|
59
|
+
if (!out.toolCalls && Array.isArray(rest.tool_calls)) out.toolCalls = rest.tool_calls;
|
|
60
|
+
if (!out.toolCallId && rest.tool_call_id) out.toolCallId = rest.tool_call_id;
|
|
61
|
+
return out;
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
id: typeof data.id === 'string' ? data.id : null,
|
|
65
|
+
createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
|
|
66
|
+
schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
|
|
67
|
+
messages,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sessionFolder(id) {
|
|
72
|
+
return path.join(baseDir(), id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readFolderSession(id) {
|
|
76
|
+
const dir = sessionFolder(id);
|
|
77
|
+
const metaFile = path.join(dir, 'session.json');
|
|
78
|
+
if (!fs.existsSync(metaFile)) return null;
|
|
79
|
+
try {
|
|
80
|
+
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
81
|
+
const messages = [];
|
|
82
|
+
const msgFile = path.join(dir, 'messages.jsonl');
|
|
83
|
+
if (fs.existsSync(msgFile)) {
|
|
84
|
+
const lines = fs.readFileSync(msgFile, 'utf8').split('\n');
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const t = line.trim();
|
|
87
|
+
if (!t) continue;
|
|
88
|
+
try { messages.push(JSON.parse(t)); } catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const norm = normalizeSession({ ...meta, messages });
|
|
92
|
+
if (!norm) return null;
|
|
93
|
+
norm.id = norm.id || id;
|
|
94
|
+
norm.file = metaFile;
|
|
95
|
+
norm.dir = dir;
|
|
96
|
+
return norm;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readFlatSession(dir, id) {
|
|
103
|
+
const f = path.join(dir, id + '.json');
|
|
104
|
+
if (!fs.existsSync(f)) return null;
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
107
|
+
const norm = normalizeSession(raw);
|
|
108
|
+
if (!norm) return null;
|
|
109
|
+
norm.id = norm.id || id;
|
|
110
|
+
norm.file = f;
|
|
111
|
+
return norm;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function saveSession(session) {
|
|
118
|
+
ensureDir();
|
|
119
|
+
const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
// Overwriting an existing session keeps its original createdAt (it is the
|
|
122
|
+
// same conversation); updatedAt always reflects this write.
|
|
123
|
+
let createdAt = now;
|
|
124
|
+
const prev = readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
|
|
125
|
+
if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
|
|
126
|
+
const messages = session.messages || [];
|
|
127
|
+
const data = {
|
|
128
|
+
id,
|
|
129
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
130
|
+
createdAt,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
provider: session.config ? session.config.provider : null,
|
|
133
|
+
model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
|
|
134
|
+
messageCount: messages.length,
|
|
135
|
+
};
|
|
136
|
+
const dir = sessionFolder(id);
|
|
137
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
const metaFile = path.join(dir, 'session.json');
|
|
139
|
+
// Write messages first, metadata last: a crash mid-save leaves the old
|
|
140
|
+
// session.json pointing at a complete messages file, never the reverse.
|
|
141
|
+
const msgFile = path.join(dir, 'messages.jsonl');
|
|
142
|
+
const tmp = msgFile + '.tmp';
|
|
143
|
+
fs.writeFileSync(tmp, messages.map((m) => JSON.stringify(m)).join('\n') + (messages.length ? '\n' : ''));
|
|
144
|
+
fs.renameSync(tmp, msgFile);
|
|
145
|
+
fs.writeFileSync(metaFile, JSON.stringify(data, null, 2));
|
|
146
|
+
return { id, file: metaFile, dir };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function listDir(dir, fromFolder) {
|
|
150
|
+
let names;
|
|
151
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
if (fromFolder) {
|
|
155
|
+
const entry = readFolderSession(name);
|
|
156
|
+
if (entry && isValidSessionId(entry.id)) out.push(entry);
|
|
157
|
+
} else if (name.endsWith('.json')) {
|
|
158
|
+
const id = name.replace(/\.json$/, '');
|
|
159
|
+
const entry = readFlatSession(dir, id);
|
|
160
|
+
if (entry && isValidSessionId(entry.id)) out.push(entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function listSessions() {
|
|
167
|
+
ensureDir();
|
|
168
|
+
// Newest first by the last write time (updatedAt, else createdAt). Folder
|
|
169
|
+
// sessions shadow same-id flat files (a migrated session must not appear
|
|
170
|
+
// twice). Legacy ~/.loom/sessions files stay listed so old history remains
|
|
171
|
+
// reachable after upgrading to the per-project folder layout.
|
|
172
|
+
const byId = new Map();
|
|
173
|
+
for (const s of listDir(legacyDir(), false)) byId.set(s.id, s);
|
|
174
|
+
for (const s of listDir(baseDir(), false)) byId.set(s.id, s);
|
|
175
|
+
for (const s of listDir(baseDir(), true)) byId.set(s.id, s);
|
|
176
|
+
const list = [...byId.values()].map((data) => {
|
|
177
|
+
let mtime = null;
|
|
178
|
+
try { mtime = fs.statSync(data.file).mtime.toISOString(); } catch {}
|
|
179
|
+
return {
|
|
180
|
+
id: data.id,
|
|
181
|
+
createdAt: data.createdAt,
|
|
182
|
+
updatedAt: data.updatedAt,
|
|
183
|
+
mtime,
|
|
184
|
+
messageCount: data.messageCount || (data.messages || []).length,
|
|
185
|
+
provider: data.provider,
|
|
186
|
+
model: data.model,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
|
|
190
|
+
return list.sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function loadSession(id) {
|
|
194
|
+
if (!isValidSessionId(id)) return null;
|
|
195
|
+
return readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deleteSession(id) {
|
|
199
|
+
if (!isValidSessionId(id)) return false;
|
|
200
|
+
let removed = false;
|
|
201
|
+
const dir = sessionFolder(id);
|
|
202
|
+
if (fs.existsSync(dir)) {
|
|
203
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); removed = true; } catch {}
|
|
204
|
+
}
|
|
205
|
+
for (const d of new Set([baseDir(), legacyDir()])) {
|
|
206
|
+
const f = path.join(d, id + '.json');
|
|
207
|
+
if (fs.existsSync(f)) {
|
|
208
|
+
try { fs.unlinkSync(f); removed = true; } catch {}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return removed;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function exportChat(session, format) {
|
|
215
|
+
const type = format || 'md';
|
|
216
|
+
const msgs = session.messages || [];
|
|
217
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
218
|
+
const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
|
|
219
|
+
if (type === 'md') {
|
|
220
|
+
const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
|
|
221
|
+
for (const m of msgs) {
|
|
222
|
+
const role = m.role || 'unknown';
|
|
223
|
+
lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
|
|
224
|
+
lines.push('');
|
|
225
|
+
lines.push(m.content || '(empty)');
|
|
226
|
+
lines.push('');
|
|
227
|
+
}
|
|
228
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
229
|
+
} else {
|
|
230
|
+
const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
|
|
231
|
+
fs.writeFileSync(file, exportLines.join('\n'));
|
|
232
|
+
}
|
|
233
|
+
return file;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
saveSession,
|
|
238
|
+
listSessions,
|
|
239
|
+
loadSession,
|
|
240
|
+
deleteSession,
|
|
241
|
+
exportChat,
|
|
242
|
+
normalizeSession,
|
|
243
|
+
isValidSessionId,
|
|
244
|
+
SESSION_SCHEMA_VERSION,
|
|
245
|
+
};
|
package/src/core/session.js
CHANGED
|
@@ -26,11 +26,29 @@ const COMPACT_MIN_MESSAGES = 6;
|
|
|
26
26
|
// Drive-to-completion: how many continuation nudges one turn may spend when
|
|
27
27
|
// the model stops with todos still unfinished (the "stops between tasks"
|
|
28
28
|
// failure). Capped so a model that refuses to update its todos cannot spin.
|
|
29
|
-
const MAX_DRIVE_NUDGES =
|
|
29
|
+
const MAX_DRIVE_NUDGES = 8;
|
|
30
|
+
// Empty-reply nudges per turn (provider hiccup / reasoning-only stop).
|
|
31
|
+
const MAX_EMPTY_NUDGES = 3;
|
|
32
|
+
// finish_reason=length (output budget exhausted) auto-continues the turn
|
|
33
|
+
// this many times before surfacing the truncation placeholder. This is the
|
|
34
|
+
// #1 cause of "stops in the middle" with reasoning models: thinking eats
|
|
35
|
+
// the whole max_tokens budget and the answer never streams.
|
|
36
|
+
const MAX_TRUNCATION_CONTINUES = 5;
|
|
30
37
|
// Transient provider failures (network blip, 5xx, stream reset) retry the
|
|
31
38
|
// model call this many times before the turn surfaces an error.
|
|
32
39
|
const PROVIDER_RETRIES = 2;
|
|
33
40
|
|
|
41
|
+
// Does this reply sound like the model stopped in the MIDDLE of working?
|
|
42
|
+
// Used when todos exist but weren't touched this turn: a short reply, or a
|
|
43
|
+
// long one that ends on a continuation promise ("Now I will…", "Next, …"),
|
|
44
|
+
// an open colon, or an ellipsis is unfinished. A full explanation is not.
|
|
45
|
+
function looksUnfinished(text) {
|
|
46
|
+
const t = String(text || '').trim();
|
|
47
|
+
if (!t) return true;
|
|
48
|
+
if (t.length < 320) return true;
|
|
49
|
+
return /(?:\b(?:now|next|then|let me|i'?ll|going to|proceeding)\b[^.!?]*[.!]?\s*$)|[:…]$|\.\.\.\s*$/i.test(t);
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
/**
|
|
35
53
|
* @typedef {Object} SpeedStats
|
|
36
54
|
* @property {number} _turnStart
|
|
@@ -245,6 +263,7 @@ ${this.loadSkills()}
|
|
|
245
263
|
## Mode
|
|
246
264
|
You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
|
|
247
265
|
${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
|
|
266
|
+
- bash runs on the user's machine. Only use the sandbox tool when isolation is genuinely needed (the user asked for it, or the command is risky/Linux-only); never for ordinary commands.
|
|
248
267
|
- Inspect, edit, and verify the result yourself end-to-end.
|
|
249
268
|
- When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
|
|
250
269
|
- Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
|
|
@@ -405,6 +424,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
405
424
|
this.messages.push(msg);
|
|
406
425
|
}
|
|
407
426
|
|
|
427
|
+
// Remove a trailing empty assistant row (no text, no toolCalls). getResponse
|
|
428
|
+
// appends the assistant message even when the provider returned nothing —
|
|
429
|
+
// Anthropic rejects empty assistant content with a 400 on the next call, so
|
|
430
|
+
// continuation paths drop the row before adding their user nudge.
|
|
431
|
+
_dropTrailingEmptyAssistant() {
|
|
432
|
+
const last = this.messages[this.messages.length - 1];
|
|
433
|
+
if (last && last.role === 'assistant' && !(last.toolCalls || []).length && !String(last.content || '').trim()) {
|
|
434
|
+
this.messages.pop();
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
408
440
|
// ─── Todo state ───
|
|
409
441
|
// The todowrite tool persists into the session so the sidebar shows the
|
|
410
442
|
// real, up-to-date task list instead of regex-scanning replies.
|
|
@@ -568,10 +600,11 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
568
600
|
// No tool-use cap: the model decides when a turn is finished; the user can
|
|
569
601
|
// always Esc-interrupt a runaway loop.
|
|
570
602
|
let ranTools = false;
|
|
571
|
-
let
|
|
603
|
+
let emptyNudges = 0;
|
|
572
604
|
let toolCallsThisTurn = 0;
|
|
573
605
|
// Drive-to-completion state (see the no-toolCalls branch below).
|
|
574
606
|
let driveNudges = 0;
|
|
607
|
+
let truncContinues = 0;
|
|
575
608
|
let todowroteThisTurn = false;
|
|
576
609
|
// Transient provider-error retry state.
|
|
577
610
|
let providerRetries = 0;
|
|
@@ -644,30 +677,58 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
644
677
|
|
|
645
678
|
if (!toolCalls.length) {
|
|
646
679
|
const text = String(resp.content || '');
|
|
680
|
+
|
|
681
|
+
// Truncation auto-continue: finish_reason=length means the reply was
|
|
682
|
+
// CUT OFF by maxTokens — common with reasoning models (thinking eats
|
|
683
|
+
// the budget, the final answer never streams). Instead of dead-ending
|
|
684
|
+
// with a placeholder (the classic "stops in the middle"), continue the
|
|
685
|
+
// turn. Empty-content truncations also drop the empty assistant row
|
|
686
|
+
// from history — Anthropic 400s on empty assistant messages.
|
|
687
|
+
if (!this.interrupted && resp.finishReason === 'length' && truncContinues < MAX_TRUNCATION_CONTINUES) {
|
|
688
|
+
truncContinues++;
|
|
689
|
+
if (!text.trim()) this._dropTrailingEmptyAssistant();
|
|
690
|
+
const at = text.trim() ? text.replace(/\s+/g, ' ').trim().slice(-160) : null;
|
|
691
|
+
this.addMessage({
|
|
692
|
+
role: 'user',
|
|
693
|
+
content: '(your reply was cut off by the output-token limit' +
|
|
694
|
+
(at ? ' right after: \u2026' + at : ' before any text streamed') +
|
|
695
|
+
' \u2014 continue exactly from there and finish the task. Keep the remaining reply compact so it fits.)',
|
|
696
|
+
});
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
|
|
647
700
|
// Silent-stop guard: models occasionally end a turn with an EMPTY
|
|
648
|
-
// reply right after running tools (truncation / provider hiccup)
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
|
|
652
|
-
|
|
701
|
+
// reply — right after running tools (truncation / provider hiccup)
|
|
702
|
+
// or even on a plain first reply (reasoning-only stop, content
|
|
703
|
+
// filter). Automatic continuation instead of dead-ending with a
|
|
704
|
+
// placeholder — up to MAX_EMPTY_NUDGES per turn (hiccups repeat).
|
|
705
|
+
// finish_reason=length skips this: the truncation block above already
|
|
706
|
+
// spent its budget, and the placeholder below explains why.
|
|
707
|
+
if (!text.trim() && emptyNudges < MAX_EMPTY_NUDGES && !this.interrupted && resp.finishReason !== 'length') {
|
|
708
|
+
emptyNudges++;
|
|
709
|
+
this._dropTrailingEmptyAssistant();
|
|
653
710
|
this.addMessage({
|
|
654
711
|
role: 'user',
|
|
655
|
-
content:
|
|
656
|
-
'
|
|
712
|
+
content: ranTools
|
|
713
|
+
? '(the model returned an empty reply after ' + toolCallsThisTurn +
|
|
714
|
+
' tool call(s) — continue where you left off and finish the task)'
|
|
715
|
+
: '(your last reply was empty — respond to the user with text, or use tools to do the work)',
|
|
657
716
|
});
|
|
658
717
|
continue;
|
|
659
718
|
}
|
|
660
719
|
// Drive-to-completion guard: the model stopped calling tools in the
|
|
661
|
-
// middle of tracked work
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
720
|
+
// middle of tracked work. Two triggers:
|
|
721
|
+
// 1. todos written THIS turn are still pending/in_progress (the
|
|
722
|
+
// strong signal — always nudge), or
|
|
723
|
+
// 2. tools ran this turn, todos exist, and the reply LOOKS
|
|
724
|
+
// unfinished (short / ends on a promise) — catches premature
|
|
725
|
+
// wrap-ups from models that never call todowrite.
|
|
726
|
+
// Up to MAX_DRIVE_NUDGES continuations. Plan/chat modes are excluded
|
|
727
|
+
// (plan drafts are meant to end with pending steps).
|
|
728
|
+
if (!this.interrupted && this.mode === 'build' && ranTools && driveNudges < MAX_DRIVE_NUDGES) {
|
|
669
729
|
const unfinished = (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress');
|
|
670
|
-
|
|
730
|
+
const gated = todowroteThisTurn || looksUnfinished(text);
|
|
731
|
+
if (unfinished.length && gated) {
|
|
671
732
|
driveNudges++;
|
|
672
733
|
const list = unfinished.slice(0, 20).map((t) => '- [' + t.status + '] ' + t.content).join('\n');
|
|
673
734
|
// Quote what the model actually said — a canned nudge alone gives
|
|
@@ -685,16 +746,21 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
685
746
|
continue;
|
|
686
747
|
}
|
|
687
748
|
}
|
|
688
|
-
// Surface WHAT is still open when a build-mode turn
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
const openTodos = (this.mode === 'build' && todowroteThisTurn)
|
|
749
|
+
// Surface WHAT is still open when a build-mode turn engaged the drive
|
|
750
|
+
// guard (or wrote todos) and ends with work remaining, so the UI can
|
|
751
|
+
// show the real leftovers instead of a generic "stopped" placeholder.
|
|
752
|
+
const openTodos = (this.mode === 'build' && (todowroteThisTurn || driveNudges > 0))
|
|
693
753
|
? (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
|
|
694
754
|
: [];
|
|
755
|
+
// Safety net: after all continuation budgets are spent, a
|
|
756
|
+
// finish_reason=length reply still gets the explanatory placeholder
|
|
757
|
+
// instead of mystery silence.
|
|
758
|
+
const truncHint = resp.finishReason === 'length'
|
|
759
|
+
? '(the reply hit the max-tokens limit mid-stream — the model ran out of output budget before writing any text. Raise maxTokens in config, or /think off to free the thinking budget)'
|
|
760
|
+
: '(the model ended the turn without any text)';
|
|
695
761
|
return {
|
|
696
762
|
type: 'text',
|
|
697
|
-
content: text ||
|
|
763
|
+
content: text || truncHint,
|
|
698
764
|
openTodos: openTodos.length ? openTodos : undefined,
|
|
699
765
|
};
|
|
700
766
|
}
|
|
@@ -50,10 +50,15 @@ function buildBody(messages, options) {
|
|
|
50
50
|
continue;
|
|
51
51
|
}
|
|
52
52
|
if (m.role === 'tool') {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
// tool_use_id is required by the API — skip orphaned tool rows from
|
|
54
|
+
// resumed/hand-edited sessions rather than failing the whole turn.
|
|
55
|
+
const tcid = m.toolCallId || m.tool_call_id;
|
|
56
|
+
if (tcid) {
|
|
57
|
+
out.push({
|
|
58
|
+
role: 'user',
|
|
59
|
+
content: [{ type: 'tool_result', tool_use_id: tcid, content: String(m.content) }],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
57
62
|
continue;
|
|
58
63
|
}
|
|
59
64
|
if (m.role === 'assistant') {
|
|
@@ -70,7 +75,7 @@ function buildBody(messages, options) {
|
|
|
70
75
|
} else if (m.content) {
|
|
71
76
|
blocks.push({ type: 'text', text: m.content });
|
|
72
77
|
}
|
|
73
|
-
for (const tc of m.toolCalls || []) {
|
|
78
|
+
for (const tc of (m.toolCalls || m.tool_calls) || []) {
|
|
74
79
|
blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input || {} });
|
|
75
80
|
}
|
|
76
81
|
out.push({ role: 'assistant', content: blocks });
|