@yeaft/webchat-agent 0.1.951 → 0.1.953
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/package.json +1 -1
- package/workbench/transfer.js +74 -1
- package/yeaft/engine.js +48 -3
- package/yeaft/sub-agent/liveness.js +101 -0
- package/yeaft/sub-agent/notifications.js +257 -0
- package/yeaft/sub-agent/output-log.js +237 -0
- package/yeaft/sub-agent/runner.js +332 -146
- package/yeaft/sub-agent/status.js +81 -0
- package/yeaft/tools/agent.js +70 -10
- package/yeaft/tools/close-agent.js +55 -9
- package/yeaft/tools/list-agents.js +40 -11
- package/yeaft/tools/send-message.js +36 -11
- package/yeaft/tools/wait-agent.js +168 -110
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* output-log.js — Durable per-sub-agent event log.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* The original sub-agent design relied entirely on the live `onEvent`
|
|
6
|
+
* callback for visibility. If the UI dropped a frame, the WebSocket
|
|
7
|
+
* reconnected, or the user backgrounded the tab, the sub-agent's
|
|
8
|
+
* output was simply gone — there was no way to re-read it. The parent
|
|
9
|
+
* model had even less recourse: WaitAgent only returned the assistant
|
|
10
|
+
* text from the very last completed turn (and only at end_turn), so a
|
|
11
|
+
* sub-agent that ran a long tool sequence looked silent from upstairs.
|
|
12
|
+
*
|
|
13
|
+
* This module gives every sub-agent a durable JSONL log on disk. Every
|
|
14
|
+
* event the runner forwards through onEvent is mirrored here. The log
|
|
15
|
+
* path is exposed to the parent through WaitAgent/ListAgents, and the
|
|
16
|
+
* parent can Read it at any time. We also expose a `tail()` helper for
|
|
17
|
+
* wait_agent to include a small preview inline.
|
|
18
|
+
*
|
|
19
|
+
* Modelled on claude-code's `outputFile` discipline (LocalAgentTask).
|
|
20
|
+
*
|
|
21
|
+
* Format: one JSON object per line:
|
|
22
|
+
* { t: <ms epoch>, type: <evt.type>, ...payload }
|
|
23
|
+
*
|
|
24
|
+
* Size cap:
|
|
25
|
+
* Bounded by MAX_BYTES (~2 MiB). When the file would exceed the cap
|
|
26
|
+
* the writer rotates: rename `<id>.log` → `<id>.log.1` (overwriting any
|
|
27
|
+
* prior `.1`), and start a fresh `.log`. We keep exactly one rotation
|
|
28
|
+
* slot — no log forest — because sub-agents are ephemeral and we just
|
|
29
|
+
* want "the recent past" to remain readable.
|
|
30
|
+
*
|
|
31
|
+
* No external lock: each sub-agent owns its own file; we never write to
|
|
32
|
+
* another agent's log. Multiple processes are not a concern (Yeaft is a
|
|
33
|
+
* single agent process).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'fs';
|
|
37
|
+
import path from 'path';
|
|
38
|
+
import os from 'os';
|
|
39
|
+
|
|
40
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.yeaft', 'sub-agents');
|
|
41
|
+
const MAX_BYTES = 2 * 1024 * 1024; // 2 MiB before rotation
|
|
42
|
+
const TAIL_DEFAULT_BYTES = 8 * 1024; // 8 KiB tail preview
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the log file for an agentId under the chosen base directory.
|
|
46
|
+
* Caller controls the dir so tests can write into a tmp dir without
|
|
47
|
+
* stamping on ~/.yeaft.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} agentId
|
|
50
|
+
* @param {string} [baseDir]
|
|
51
|
+
* @returns {string} absolute path
|
|
52
|
+
*/
|
|
53
|
+
export function resolveLogPath(agentId, baseDir = DEFAULT_DIR) {
|
|
54
|
+
if (!agentId || typeof agentId !== 'string') {
|
|
55
|
+
throw new Error('resolveLogPath: agentId is required');
|
|
56
|
+
}
|
|
57
|
+
// Defensive: never let an agentId escape the dir via traversal.
|
|
58
|
+
if (agentId.includes('/') || agentId.includes('\\') || agentId.includes('..')) {
|
|
59
|
+
throw new Error(`resolveLogPath: unsafe agentId "${agentId}"`);
|
|
60
|
+
}
|
|
61
|
+
return path.join(baseDir, `${agentId}.log`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a log sink for an agent. Returns:
|
|
66
|
+
* { path, write(evt), close(), tail(maxBytes?), size() }
|
|
67
|
+
*
|
|
68
|
+
* `write` is best-effort: a disk error is logged once on stderr and
|
|
69
|
+
* subsequent writes silently no-op for this sink. We never want a log
|
|
70
|
+
* failure to crash the driver.
|
|
71
|
+
*
|
|
72
|
+
* `tail(maxBytes)` returns the last N bytes of the file (as a string).
|
|
73
|
+
* If the file doesn't exist yet, returns ''.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} agentId
|
|
76
|
+
* @param {string} [baseDir]
|
|
77
|
+
* @returns {{ path: string, write: (evt: object) => void, close: () => void, tail: (maxBytes?: number) => string, size: () => number }}
|
|
78
|
+
*/
|
|
79
|
+
export function createOutputLog(agentId, baseDir = DEFAULT_DIR) {
|
|
80
|
+
const filePath = resolveLogPath(agentId, baseDir);
|
|
81
|
+
let closed = false;
|
|
82
|
+
let writeFailed = false;
|
|
83
|
+
let currentSize = 0;
|
|
84
|
+
|
|
85
|
+
// Best-effort dir creation; if it fails the writer will go into
|
|
86
|
+
// writeFailed mode below.
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
writeFailed = true;
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn(`[sub-agent] could not create log dir ${path.dirname(filePath)}: ${err?.message || err}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// If a prior log exists for this id (process restart, name reuse with
|
|
96
|
+
// include_closed=false later, etc.) seed currentSize so rotation
|
|
97
|
+
// calculations are correct.
|
|
98
|
+
if (!writeFailed) {
|
|
99
|
+
try {
|
|
100
|
+
const st = fs.statSync(filePath);
|
|
101
|
+
currentSize = st.size;
|
|
102
|
+
} catch { /* file may not exist yet — that's fine */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rotate() {
|
|
106
|
+
try {
|
|
107
|
+
const rotated = `${filePath}.1`;
|
|
108
|
+
try { fs.unlinkSync(rotated); } catch { /* may not exist */ }
|
|
109
|
+
fs.renameSync(filePath, rotated);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Rotation failed — best-effort: truncate the current file so we
|
|
112
|
+
// don't grow without bound.
|
|
113
|
+
try { fs.truncateSync(filePath, 0); } catch { /* ignore */ }
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.warn(`[sub-agent] log rotation failed for ${filePath}: ${err?.message || err}`);
|
|
116
|
+
}
|
|
117
|
+
currentSize = 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function write(evt) {
|
|
121
|
+
if (closed || writeFailed) return;
|
|
122
|
+
let line;
|
|
123
|
+
try {
|
|
124
|
+
const safe = serialize(evt);
|
|
125
|
+
line = JSON.stringify({ t: Date.now(), ...safe }) + '\n';
|
|
126
|
+
} catch {
|
|
127
|
+
// Unserializable event — try a minimal record so the timeline at
|
|
128
|
+
// least notes that "something happened".
|
|
129
|
+
line = JSON.stringify({ t: Date.now(), type: evt?.type || 'unknown', _unserializable: true }) + '\n';
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
if (currentSize + line.length > MAX_BYTES) {
|
|
133
|
+
rotate();
|
|
134
|
+
}
|
|
135
|
+
fs.appendFileSync(filePath, line);
|
|
136
|
+
currentSize += line.length;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
writeFailed = true;
|
|
139
|
+
// eslint-disable-next-line no-console
|
|
140
|
+
console.warn(`[sub-agent] log write failed for ${filePath}: ${err?.message || err}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function close() {
|
|
145
|
+
closed = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tail(maxBytes = TAIL_DEFAULT_BYTES) {
|
|
149
|
+
if (writeFailed) return '';
|
|
150
|
+
try {
|
|
151
|
+
const st = fs.statSync(filePath);
|
|
152
|
+
if (st.size === 0) return '';
|
|
153
|
+
const fd = fs.openSync(filePath, 'r');
|
|
154
|
+
try {
|
|
155
|
+
const readLen = Math.min(maxBytes, st.size);
|
|
156
|
+
const buf = Buffer.alloc(readLen);
|
|
157
|
+
fs.readSync(fd, buf, 0, readLen, st.size - readLen);
|
|
158
|
+
// Drop a partial leading line so the tail starts on a clean
|
|
159
|
+
// record boundary (unless we read from the very start).
|
|
160
|
+
let text = buf.toString('utf8');
|
|
161
|
+
if (st.size > readLen) {
|
|
162
|
+
const nl = text.indexOf('\n');
|
|
163
|
+
if (nl >= 0) text = text.slice(nl + 1);
|
|
164
|
+
}
|
|
165
|
+
return text;
|
|
166
|
+
} finally {
|
|
167
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function size() {
|
|
175
|
+
return currentSize;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { path: filePath, write, close, tail, size };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Trim event payloads so the log line stays bounded and serializable.
|
|
183
|
+
* We keep type + small-ish text payloads; drop heavyweight fields like
|
|
184
|
+
* full message arrays or binary buffers.
|
|
185
|
+
*/
|
|
186
|
+
function serialize(evt) {
|
|
187
|
+
if (!evt || typeof evt !== 'object') {
|
|
188
|
+
return { type: 'unknown', value: evt == null ? null : String(evt) };
|
|
189
|
+
}
|
|
190
|
+
const out = { type: evt.type || 'unknown' };
|
|
191
|
+
if (evt.agentId) out.agentId = evt.agentId;
|
|
192
|
+
if (evt.agentName) out.agentName = evt.agentName;
|
|
193
|
+
if (typeof evt.text === 'string') {
|
|
194
|
+
out.text = evt.text.length > 2048 ? evt.text.slice(0, 2048) + '…' : evt.text;
|
|
195
|
+
}
|
|
196
|
+
if (typeof evt.content === 'string') {
|
|
197
|
+
out.content = evt.content.length > 2048 ? evt.content.slice(0, 2048) + '…' : evt.content;
|
|
198
|
+
}
|
|
199
|
+
if (evt.stopReason) out.stopReason = evt.stopReason;
|
|
200
|
+
if (evt.toolName) out.toolName = evt.toolName;
|
|
201
|
+
if (evt.toolUseId) out.toolUseId = evt.toolUseId;
|
|
202
|
+
if (evt.error) {
|
|
203
|
+
out.error = typeof evt.error === 'string'
|
|
204
|
+
? evt.error
|
|
205
|
+
: (evt.error.message || String(evt.error));
|
|
206
|
+
}
|
|
207
|
+
if (evt.status) out.status = evt.status;
|
|
208
|
+
if (typeof evt.tokens === 'number') out.tokens = evt.tokens;
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read the whole log for an agent as an array of parsed records.
|
|
214
|
+
* Best-effort: lines that fail to parse are skipped (with `_raw`).
|
|
215
|
+
* Used by tests and by the optional /yeaft_fetch_sub_agent_log surface.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} agentId
|
|
218
|
+
* @param {string} [baseDir]
|
|
219
|
+
* @returns {Array<object>}
|
|
220
|
+
*/
|
|
221
|
+
export function readOutputLog(agentId, baseDir = DEFAULT_DIR) {
|
|
222
|
+
const filePath = resolveLogPath(agentId, baseDir);
|
|
223
|
+
try {
|
|
224
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
225
|
+
const lines = text.split('\n').filter(Boolean);
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
try { out.push(JSON.parse(line)); }
|
|
229
|
+
catch { out.push({ _raw: line }); }
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const _internals = { MAX_BYTES, TAIL_DEFAULT_BYTES, DEFAULT_DIR };
|