@aion0/forge 0.10.22 → 0.10.23
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/RELEASE_NOTES.md +22 -4
- package/app/api/watches/[id]/route.ts +25 -0
- package/app/api/watches/route.ts +17 -0
- package/app/chat/page.tsx +66 -4
- package/components/Dashboard.tsx +21 -5
- package/components/MonitorPanel.tsx +88 -0
- package/components/WatchesPanel.tsx +97 -0
- package/docs/forge-long-task-watch-design.md +15 -2
- package/lib/chat/agent-loop.ts +32 -2
- package/lib/chat/tool-dispatcher.ts +45 -5
- package/lib/chat-standalone.ts +12 -0
- package/lib/connectors/types.ts +56 -0
- package/lib/help-docs/21-build-connector.md +42 -0
- package/lib/help-docs/24-watch.md +77 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/watch/register.ts +108 -0
- package/lib/watch/start-watch-tool.ts +116 -0
- package/lib/watch/template.ts +40 -0
- package/lib/watch/watch-runner.ts +158 -0
- package/lib/watch/watch-store.ts +218 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.23
|
|
2
2
|
|
|
3
|
-
Released: 2026-
|
|
3
|
+
Released: 2026-06-01
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.22
|
|
6
6
|
|
|
7
|
+
### Features
|
|
8
|
+
- feat: long-task watch — lightweight async background tool-call primitive for chat
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
### Documentation
|
|
11
|
+
- docs: mark watch backend implemented on feat/watch
|
|
12
|
+
|
|
13
|
+
### Other
|
|
14
|
+
- fix(chat): cap tool_result at 16k chars — oversized result (e.g. full test tree) no longer blows the history budget → orphan strip → empty messages → provider 'messages must not be empty'; + clear-error guard if history is ever empty
|
|
15
|
+
- feat(dashboard): expandable Alerts/notifications — click a row to see full title+body (was truncated, no way to read in full); auto-marks read on expand
|
|
16
|
+
- docs(help): add 24-watch.md (background watches: async block + start_watch, where to see/cancel) + index/routing
|
|
17
|
+
- feat(watch): start_watch builtin — LLM-driven dynamic watch (poll/done/interval), coexists with declarative async blocks
|
|
18
|
+
- feat(chat): show per-message timestamp in /chat (time-of-day, or date+time if older)
|
|
19
|
+
- docs(watch): document terminal watch_status → immediate chip removal (prune is fallback)
|
|
20
|
+
- fix(watch): emit terminal watch_status on finish so the progress chip clears immediately (was lingering until 150s prune)
|
|
21
|
+
- feat(watch): Background Watches section in MonitorPanel (list + cancel/delete)
|
|
22
|
+
- fix(watch): pre-resolve {args.*}/{result.*}/{settings.*} in watch messages at register time
|
|
23
|
+
- feat(watch): /chat UI — ambient progress chip (watch_status) + Background watches panel (list/cancel/delete); help doc for async block
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.22...v0.10.23
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/watches/:id { action: "cancel" } — stop an active watch.
|
|
3
|
+
* DELETE /api/watches/:id — remove the row.
|
|
4
|
+
*
|
|
5
|
+
* Writes go to workflow.db; the runner (chat-standalone) reads state each
|
|
6
|
+
* tick, so a cancel here takes effect within one tick without IPC.
|
|
7
|
+
*/
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { cancelWatch, deleteWatch, getWatch } from '@/lib/watch/watch-store';
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
12
|
+
const { id } = await params;
|
|
13
|
+
const body = await req.json().catch(() => ({}));
|
|
14
|
+
const action = String(body?.action || 'cancel');
|
|
15
|
+
if (action !== 'cancel') return NextResponse.json({ error: `unknown action "${action}"` }, { status: 400 });
|
|
16
|
+
if (!getWatch(id)) return NextResponse.json({ error: 'watch not found' }, { status: 404 });
|
|
17
|
+
const ok = cancelWatch(id, Date.now());
|
|
18
|
+
return NextResponse.json({ ok, watch: getWatch(id) });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
22
|
+
const { id } = await params;
|
|
23
|
+
const ok = deleteWatch(id);
|
|
24
|
+
return NextResponse.json({ ok });
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/watches — list background long-task watches (active first,
|
|
3
|
+
* then recent terminal). Read-only management surface for the watch
|
|
4
|
+
* runner that lives in chat-standalone; both share workflow.db.
|
|
5
|
+
*/
|
|
6
|
+
import { NextResponse } from 'next/server';
|
|
7
|
+
import { listWatches } from '@/lib/watch/watch-store';
|
|
8
|
+
|
|
9
|
+
export async function GET(req: Request) {
|
|
10
|
+
const url = new URL(req.url);
|
|
11
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
|
12
|
+
try {
|
|
13
|
+
return NextResponse.json({ watches: listWatches(limit) });
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
16
|
+
}
|
|
17
|
+
}
|
package/app/chat/page.tsx
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
23
|
import MarkdownContent from '@/components/MarkdownContent';
|
|
24
|
+
import WatchesPanel from '@/components/WatchesPanel';
|
|
24
25
|
import type { ContentBlock, Message, Session } from '@/lib/chat/types';
|
|
25
26
|
|
|
26
27
|
const PROXY = '/api/chat-proxy';
|
|
@@ -46,6 +47,9 @@ export default function ChatPage() {
|
|
|
46
47
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
47
48
|
const [activeId, setActiveId] = useState<string>('');
|
|
48
49
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
50
|
+
// Background-watch progress chips (watch_id → {text, ts}). Ambient,
|
|
51
|
+
// updated in place; pruned when stale. Not part of the message thread.
|
|
52
|
+
const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
|
|
49
53
|
const [input, setInput] = useState('');
|
|
50
54
|
const [streaming, setStreaming] = useState(false);
|
|
51
55
|
const [partial, setPartial] = useState('');
|
|
@@ -129,6 +133,18 @@ export default function ChatPage() {
|
|
|
129
133
|
setPartial('');
|
|
130
134
|
loadMessages(activeId);
|
|
131
135
|
refreshSessions();
|
|
136
|
+
} else if (type === 'watch_status') {
|
|
137
|
+
// Ambient background-watch progress — a chip that updates in
|
|
138
|
+
// place, NOT a chat message (doesn't touch the thread / LLM).
|
|
139
|
+
// A terminal status (done/non-active) drops the chip at once.
|
|
140
|
+
const wid = String(data.watch_id || '');
|
|
141
|
+
if (wid) {
|
|
142
|
+
const terminal = !!data.done || (data.state && data.state !== 'active');
|
|
143
|
+
setWatchChips((c) => {
|
|
144
|
+
if (terminal) { const n = { ...c }; delete n[wid]; return n; }
|
|
145
|
+
return { ...c, [wid]: { text: String(data.text || ''), ts: Date.now() } };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
132
148
|
} else if (type === 'error') {
|
|
133
149
|
setStreaming(false);
|
|
134
150
|
setError(String(data.error || 'unknown error'));
|
|
@@ -145,6 +161,22 @@ export default function ChatPage() {
|
|
|
145
161
|
if (el) el.scrollTop = el.scrollHeight;
|
|
146
162
|
}, [messages, partial]);
|
|
147
163
|
|
|
164
|
+
// ─── Prune stale watch chips (no update in >150s = done/gone) ──
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const t = setInterval(() => {
|
|
167
|
+
setWatchChips((c) => {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const next: typeof c = {};
|
|
170
|
+
let changed = false;
|
|
171
|
+
for (const [k, v] of Object.entries(c)) {
|
|
172
|
+
if (now - v.ts < 150_000) next[k] = v; else changed = true;
|
|
173
|
+
}
|
|
174
|
+
return changed ? next : c;
|
|
175
|
+
});
|
|
176
|
+
}, 30_000);
|
|
177
|
+
return () => clearInterval(t);
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
148
180
|
// ─── Auto-resize composer ─────────────────────────────────
|
|
149
181
|
useEffect(() => {
|
|
150
182
|
const el = composerRef.current;
|
|
@@ -282,6 +314,8 @@ export default function ChatPage() {
|
|
|
282
314
|
})}
|
|
283
315
|
</div>
|
|
284
316
|
|
|
317
|
+
<WatchesPanel />
|
|
318
|
+
|
|
285
319
|
<div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
|
|
286
320
|
<button
|
|
287
321
|
type="button"
|
|
@@ -369,6 +403,23 @@ export default function ChatPage() {
|
|
|
369
403
|
</div>
|
|
370
404
|
</div>
|
|
371
405
|
|
|
406
|
+
{Object.keys(watchChips).length > 0 && (
|
|
407
|
+
<div className="px-6 pt-2">
|
|
408
|
+
<div className="max-w-3xl mx-auto flex flex-wrap gap-2">
|
|
409
|
+
{Object.entries(watchChips).map(([id, w]) => (
|
|
410
|
+
<span
|
|
411
|
+
key={id}
|
|
412
|
+
title={id}
|
|
413
|
+
className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
|
|
414
|
+
>
|
|
415
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
416
|
+
{w.text}
|
|
417
|
+
</span>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
372
423
|
<form
|
|
373
424
|
className="border-t border-[var(--border)] px-6 py-4"
|
|
374
425
|
onSubmit={(e) => { e.preventDefault(); send(); }}
|
|
@@ -411,7 +462,17 @@ export default function ChatPage() {
|
|
|
411
462
|
|
|
412
463
|
// ─── Message renderers ────────────────────────────────────
|
|
413
464
|
|
|
414
|
-
function
|
|
465
|
+
function fmtTs(ts?: number): string {
|
|
466
|
+
if (!ts) return '';
|
|
467
|
+
const d = new Date(ts);
|
|
468
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
471
|
+
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
472
|
+
return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: number; children: React.ReactNode }) {
|
|
415
476
|
const isUser = role === 'user';
|
|
416
477
|
return (
|
|
417
478
|
<div className="flex gap-4">
|
|
@@ -426,8 +487,9 @@ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: R
|
|
|
426
487
|
{isUser ? 'U' : 'AI'}
|
|
427
488
|
</div>
|
|
428
489
|
<div className="flex-1 min-w-0">
|
|
429
|
-
<div className="
|
|
430
|
-
{role}
|
|
490
|
+
<div className="flex items-baseline gap-2 mb-1">
|
|
491
|
+
<span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">{role}</span>
|
|
492
|
+
{ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
|
|
431
493
|
</div>
|
|
432
494
|
<div className="space-y-2">{children}</div>
|
|
433
495
|
</div>
|
|
@@ -437,7 +499,7 @@ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: R
|
|
|
437
499
|
|
|
438
500
|
function MessageView({ m }: { m: Message }) {
|
|
439
501
|
return (
|
|
440
|
-
<RoleBlock role={m.role}>
|
|
502
|
+
<RoleBlock role={m.role} ts={m.ts}>
|
|
441
503
|
{m.blocks.map((b, i) => (
|
|
442
504
|
<BlockView key={i} b={b} />
|
|
443
505
|
))}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -141,6 +141,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
141
141
|
const [notifications, setNotifications] = useState<any[]>([]);
|
|
142
142
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
143
143
|
const [showNotifications, setShowNotifications] = useState(false);
|
|
144
|
+
const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
|
|
144
145
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
145
146
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
|
146
147
|
const [displayName, setDisplayName] = useState(user?.name || 'Forge');
|
|
@@ -511,10 +512,23 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
511
512
|
{notifications.length === 0 ? (
|
|
512
513
|
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">No notifications</div>
|
|
513
514
|
) : (
|
|
514
|
-
notifications.map((n: any) =>
|
|
515
|
+
notifications.map((n: any) => {
|
|
516
|
+
const expanded = expandedNotif === n.id;
|
|
517
|
+
return (
|
|
515
518
|
<div
|
|
516
519
|
key={n.id}
|
|
517
|
-
|
|
520
|
+
onClick={async () => {
|
|
521
|
+
setExpandedNotif(expanded ? null : n.id);
|
|
522
|
+
if (!expanded && !n.read) {
|
|
523
|
+
await fetch('/api/notifications', {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'Content-Type': 'application/json' },
|
|
526
|
+
body: JSON.stringify({ action: 'markRead', id: n.id }),
|
|
527
|
+
});
|
|
528
|
+
fetchNotifications();
|
|
529
|
+
}
|
|
530
|
+
}}
|
|
531
|
+
className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] cursor-pointer ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
|
|
518
532
|
>
|
|
519
533
|
<div className="flex items-start gap-2">
|
|
520
534
|
<span className="text-[10px] mt-0.5 shrink-0">
|
|
@@ -522,16 +536,17 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
522
536
|
</span>
|
|
523
537
|
<div className="flex-1 min-w-0">
|
|
524
538
|
<div className="flex items-center gap-1">
|
|
525
|
-
<span className={`text-[11px] truncate ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
|
539
|
+
<span className={`text-[11px] ${expanded ? 'break-words whitespace-pre-wrap' : 'truncate'} ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
|
526
540
|
{n.title}
|
|
527
541
|
</span>
|
|
528
542
|
{!n.read && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />}
|
|
529
543
|
</div>
|
|
530
544
|
{n.body && (
|
|
531
|
-
<p className=
|
|
545
|
+
<p className={`text-[9px] text-[var(--text-secondary)] mt-0.5 ${expanded ? 'break-words whitespace-pre-wrap' : 'truncate'}`}>{n.body}</p>
|
|
532
546
|
)}
|
|
533
547
|
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
534
548
|
{new Date(n.createdAt).toLocaleString()}
|
|
549
|
+
<span className="ml-1 opacity-50">· {expanded ? 'click to collapse' : 'click for detail'}</span>
|
|
535
550
|
</span>
|
|
536
551
|
</div>
|
|
537
552
|
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
|
|
@@ -568,7 +583,8 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
568
583
|
</div>
|
|
569
584
|
</div>
|
|
570
585
|
</div>
|
|
571
|
-
|
|
586
|
+
);
|
|
587
|
+
})
|
|
572
588
|
)}
|
|
573
589
|
</div>
|
|
574
590
|
</div>
|
|
@@ -157,6 +157,9 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
|
|
|
157
157
|
</div>
|
|
158
158
|
)}
|
|
159
159
|
</div>
|
|
160
|
+
|
|
161
|
+
{/* Background watches — long-task pollers (nac upgrade etc.) */}
|
|
162
|
+
<MonitorWatches />
|
|
160
163
|
</div>
|
|
161
164
|
) : (
|
|
162
165
|
<div className="p-8 text-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -165,3 +168,88 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
|
|
|
165
168
|
</div>
|
|
166
169
|
);
|
|
167
170
|
}
|
|
171
|
+
|
|
172
|
+
// ─── Background watches ────────────────────────────────────
|
|
173
|
+
// Long-task pollers registered by `async` connector tools (e.g. nac
|
|
174
|
+
// upgrade → poll get_version until the build matches). Same data as the
|
|
175
|
+
// /chat panel; lives here so it's visible alongside the other running
|
|
176
|
+
// background resources. Reads /api/watches; cancel/delete per [id].
|
|
177
|
+
|
|
178
|
+
interface WatchRow {
|
|
179
|
+
id: string;
|
|
180
|
+
label: string;
|
|
181
|
+
state: 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
|
|
182
|
+
polls: number;
|
|
183
|
+
max_polls: number;
|
|
184
|
+
last_text: string | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const WATCH_STATE_COLOR: Record<string, string> = {
|
|
188
|
+
active: 'text-amber-400',
|
|
189
|
+
done: 'text-green-400',
|
|
190
|
+
failed: 'text-red-400',
|
|
191
|
+
timed_out: 'text-red-400',
|
|
192
|
+
errored: 'text-red-400',
|
|
193
|
+
cancelled: 'text-[var(--text-secondary)]',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
function MonitorWatches() {
|
|
197
|
+
const [watches, setWatches] = useState<WatchRow[]>([]);
|
|
198
|
+
const load = useCallback(() => {
|
|
199
|
+
fetch('/api/watches?limit=30')
|
|
200
|
+
.then(r => r.json())
|
|
201
|
+
.then(j => setWatches(Array.isArray(j.watches) ? j.watches : []))
|
|
202
|
+
.catch(() => {});
|
|
203
|
+
}, []);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
load();
|
|
206
|
+
const t = setInterval(load, 10_000);
|
|
207
|
+
return () => clearInterval(t);
|
|
208
|
+
}, [load]);
|
|
209
|
+
|
|
210
|
+
const act = async (id: string, method: 'POST' | 'DELETE') => {
|
|
211
|
+
try {
|
|
212
|
+
await fetch(`/api/watches/${id}`, {
|
|
213
|
+
method,
|
|
214
|
+
...(method === 'POST'
|
|
215
|
+
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) }
|
|
216
|
+
: {}),
|
|
217
|
+
});
|
|
218
|
+
} catch { /* ignore */ }
|
|
219
|
+
load();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const activeCount = watches.filter(w => w.state === 'active').length;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div>
|
|
226
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">
|
|
227
|
+
Background Watches ({activeCount} active)
|
|
228
|
+
</h3>
|
|
229
|
+
{watches.length === 0 ? (
|
|
230
|
+
<div className="text-[10px] text-[var(--text-secondary)]">No watches</div>
|
|
231
|
+
) : (
|
|
232
|
+
<div className="space-y-1.5">
|
|
233
|
+
{watches.map(w => (
|
|
234
|
+
<div key={w.id} className="rounded border border-[var(--border)] px-2 py-1.5">
|
|
235
|
+
<div className="flex items-center gap-2 text-[11px]">
|
|
236
|
+
<span className={`text-[9px] uppercase tracking-wide ${WATCH_STATE_COLOR[w.state] || ''}`}>{w.state}</span>
|
|
237
|
+
<span className="font-mono text-[var(--text-primary)] truncate flex-1" title={w.label}>{w.label}</span>
|
|
238
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{w.polls}/{w.max_polls}</span>
|
|
239
|
+
</div>
|
|
240
|
+
{w.last_text && (
|
|
241
|
+
<div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={w.last_text}>{w.last_text}</div>
|
|
242
|
+
)}
|
|
243
|
+
<div className="flex gap-2 mt-1">
|
|
244
|
+
{w.state === 'active' && (
|
|
245
|
+
<button type="button" onClick={() => act(w.id, 'POST')} className="text-[9px] text-amber-400 hover:underline">cancel</button>
|
|
246
|
+
)}
|
|
247
|
+
<button type="button" onClick={() => act(w.id, 'DELETE')} className="text-[9px] text-[var(--text-secondary)] hover:underline">delete</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Background-watch management — a compact list of long-task watches with
|
|
5
|
+
* cancel/delete. Reads GET /api/watches; cancel/delete via the [id] route.
|
|
6
|
+
* Lives in the /chat sidebar so users can see and rein in background
|
|
7
|
+
* pollers (avoid runaway / resource hogging). Pure backend otherwise —
|
|
8
|
+
* the watch runner lives in chat-standalone.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
interface Watch {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
state: 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
|
|
17
|
+
polls: number;
|
|
18
|
+
max_polls: number;
|
|
19
|
+
last_text: string | null;
|
|
20
|
+
updated_at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATE_COLOR: Record<string, string> = {
|
|
24
|
+
active: 'text-amber-400',
|
|
25
|
+
done: 'text-green-400',
|
|
26
|
+
failed: 'text-red-400',
|
|
27
|
+
timed_out: 'text-red-400',
|
|
28
|
+
errored: 'text-red-400',
|
|
29
|
+
cancelled: 'text-[var(--text-secondary)]',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default function WatchesPanel() {
|
|
33
|
+
const [watches, setWatches] = useState<Watch[]>([]);
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
|
|
36
|
+
const load = useCallback(() => {
|
|
37
|
+
fetch('/api/watches?limit=30')
|
|
38
|
+
.then((r) => r.json())
|
|
39
|
+
.then((j) => setWatches(Array.isArray(j.watches) ? j.watches : []))
|
|
40
|
+
.catch(() => {});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
load();
|
|
45
|
+
const t = setInterval(load, 10_000);
|
|
46
|
+
return () => clearInterval(t);
|
|
47
|
+
}, [load]);
|
|
48
|
+
|
|
49
|
+
const act = async (id: string, method: 'POST' | 'DELETE') => {
|
|
50
|
+
try {
|
|
51
|
+
await fetch(`/api/watches/${id}`, {
|
|
52
|
+
method,
|
|
53
|
+
...(method === 'POST' ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) } : {}),
|
|
54
|
+
});
|
|
55
|
+
} catch { /* ignore */ }
|
|
56
|
+
load();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const activeCount = watches.filter((w) => w.state === 'active').length;
|
|
60
|
+
if (watches.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="px-4 py-2 border-t border-[var(--border)] text-xs text-[var(--text-secondary)]">
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => setOpen((o) => !o)}
|
|
67
|
+
className="flex items-center gap-2 w-full text-left hover:text-[var(--text-primary)] transition-colors"
|
|
68
|
+
>
|
|
69
|
+
<span>Background watches</span>
|
|
70
|
+
{activeCount > 0 && (
|
|
71
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
72
|
+
)}
|
|
73
|
+
<span className="ml-auto text-[10px] opacity-60">{activeCount} active · {open ? '▾' : '▸'}</span>
|
|
74
|
+
</button>
|
|
75
|
+
{open && (
|
|
76
|
+
<div className="mt-1.5 space-y-1.5 max-h-56 overflow-y-auto">
|
|
77
|
+
{watches.map((w) => (
|
|
78
|
+
<div key={w.id} className="rounded border border-[var(--border)] px-2 py-1.5">
|
|
79
|
+
<div className="flex items-center gap-2">
|
|
80
|
+
<span className={`text-[10px] uppercase tracking-wide ${STATE_COLOR[w.state] || ''}`}>{w.state}</span>
|
|
81
|
+
<span className="truncate font-medium text-[var(--text-primary)]" title={w.label}>{w.label}</span>
|
|
82
|
+
<span className="ml-auto text-[10px] opacity-60">{w.polls}/{w.max_polls}</span>
|
|
83
|
+
</div>
|
|
84
|
+
{w.last_text && <div className="text-[10px] opacity-70 truncate mt-0.5" title={w.last_text}>{w.last_text}</div>}
|
|
85
|
+
<div className="flex gap-2 mt-1">
|
|
86
|
+
{w.state === 'active' && (
|
|
87
|
+
<button type="button" onClick={() => act(w.id, 'POST')} className="text-[10px] text-amber-400 hover:underline">cancel</button>
|
|
88
|
+
)}
|
|
89
|
+
<button type="button" onClick={() => act(w.id, 'DELETE')} className="text-[10px] opacity-60 hover:opacity-100 hover:underline">delete</button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# Forge — Long-Task Watch(轻量独立后台轮询 + 回调编排)设计方案
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 状态:**后端已实现(分支 `feat/watch`,commit ad374ec,未 merge)**。
|
|
4
|
+
> 已建:types(AsyncWatchSpec)、lib/watch/{watch-store,watch-runner,register,template}、
|
|
5
|
+
> tool-dispatcher 注册钩子、chat-standalone 启动 runner(进度→watch_status 事件 /
|
|
6
|
+
> 完成→runTurn 回灌)、app/api/watches(list/cancel/delete)。tsc 通过。
|
|
7
|
+
> **待做**:① UI——extension + /chat 订阅 `watch_status` 渲染状态条 chip、Settings→Monitor
|
|
8
|
+
> 的 watch 列表+cancel/delete;② 给 nac.upgrade 等连接器加 `async:` 块(消费者);
|
|
9
|
+
> ③ 实测 + merge。
|
|
4
10
|
> 一句话定性(zliu):watch = **在 chat 里支持的一个轻量化异步回调机制**。
|
|
5
11
|
> 消费者:TP 升级、**NAC 直连升级(nac.upgrade → 轮询 nac.get_version 直到
|
|
6
12
|
> build 匹配)**、pytest 跑——凡是"发起后要等、完成再回 chat"的都用它。
|
|
@@ -102,6 +108,13 @@ upgrade_lab:
|
|
|
102
108
|
线程。`progress.show:false` 则全程静默,只留终态。
|
|
103
109
|
- 实现:进度走 `bridgePush('watch_status', …)`(extension/web 各加一个轻量
|
|
104
110
|
status-bar 组件订阅);结果走已有的 session resume 回灌(§1)。
|
|
111
|
+
- **终态清 chip(必须显式发,别靠超时)**:watch 到终态时,`on_done`/`on_fail`
|
|
112
|
+
只走结果通道(发完成消息),**不会再发常规进度**——若不补一条信号,chip 会
|
|
113
|
+
一直挂到 prune 超时(150s)才消失,出现"结果已给、进度条还在转"的割裂。
|
|
114
|
+
所以 `finish()` 额外补发一条**终态 `watch_status`**:`{watch_id, state, done:true, text}`。
|
|
115
|
+
前端收到 `done:true`(或 `state≠active`)就**立刻删除该 chip**;否则才更新。
|
|
116
|
+
即:进度通道既负责"更新",也负责"收尾删除"。(prune 150s 仅作兜底,正常
|
|
117
|
+
路径靠这条终态信号即时清除。)
|
|
105
118
|
|
|
106
119
|
- **完成回灌 chat**:默认 `mode=chat`——把最后一次 poll 结果 + `message` 作为
|
|
107
120
|
tool-result 喂回发起的 session,助手据此吐一条消息(还能继续调用下一步)。
|
|
@@ -165,7 +178,7 @@ upgrade: # nac connector
|
|
|
165
178
|
|---|---|---|
|
|
166
179
|
| 类型 | `lib/connectors/types.ts`:`ConnectorTool.async?: AsyncWatchSpec`(poll/poll_args/done_path/done_match/fail_path/interval/timeout/max_polls/on_done/on_fail/**progress**) | 小 |
|
|
167
180
|
| 存储 | `lib/watch/watch-store.ts`(新):SQLite 表 + CRUD + 单实例守卫 | ~100 行 |
|
|
168
|
-
| 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker,单飞、护栏、终态机、调 poll/回调、**每 tick `
|
|
181
|
+
| 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker,单飞、护栏、终态机、调 poll/回调、**每 tick + 终态各发一条 `watch_status`**(终态带 `done:true` 让前端立即清 chip) | ~170 行 |
|
|
169
182
|
| 派发 | `lib/chat/tool-dispatcher.ts`:跑完带 `async` 的工具 → `registerWatch`,返回附 `watch_id` | 小 |
|
|
170
183
|
| API | `app/api/watches/route.ts` + `[id]`:list / cancel / delete | 小 |
|
|
171
184
|
| UI(状态条) | extension + /chat 各加一个轻量组件,订阅 `watch_status` push → 渲染就地更新的 chip(不进消息流) | 小 |
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { getMemoryStore } from './memory-store';
|
|
27
27
|
import { buildMemoryContext } from './build-memory-context';
|
|
28
28
|
import { buildMemoryTools } from './memory-tools';
|
|
29
|
+
import { buildStartWatchTool } from '../watch/start-watch-tool';
|
|
29
30
|
import { estimateTokens } from '../memory/token-estimate';
|
|
30
31
|
import {
|
|
31
32
|
listInstalledConnectors,
|
|
@@ -48,10 +49,25 @@ const MAX_TOKENS = 16000;
|
|
|
48
49
|
// and recalled via buildMemoryContext as compact blocks instead.
|
|
49
50
|
const HISTORY_MSG_BUDGET = 60;
|
|
50
51
|
const HISTORY_TOKEN_BUDGET = 8000;
|
|
52
|
+
// Hard cap on a single tool_result stored into the conversation (chars).
|
|
53
|
+
// A giant result (e.g. a connector returning a full test tree) would
|
|
54
|
+
// otherwise blow the whole HISTORY_TOKEN_BUDGET, push its paired
|
|
55
|
+
// assistant tool_use out of the window, and leave an orphan tool_result
|
|
56
|
+
// that trimOrphanToolResults strips — yielding an empty history and an
|
|
57
|
+
// "messages must not be empty" provider error. ~16k chars ≈ 4k tokens,
|
|
58
|
+
// half the budget, so a complete tool_use+result pair always survives.
|
|
59
|
+
const MAX_TOOL_RESULT_CHARS = 16000;
|
|
51
60
|
|
|
52
61
|
// After clipping to last N, the first kept message may be a tool_result
|
|
53
62
|
// whose tool_use was cut. Anthropic/OpenAI both reject that, so drop
|
|
54
63
|
// leading tool_result-bearing user messages until the slice starts clean.
|
|
64
|
+
function truncateToolResult(s: string): string {
|
|
65
|
+
if (s.length <= MAX_TOOL_RESULT_CHARS) return s;
|
|
66
|
+
return s.slice(0, MAX_TOOL_RESULT_CHARS) +
|
|
67
|
+
`\n\n[… tool result truncated: ${s.length} chars total, showing first ${MAX_TOOL_RESULT_CHARS}. ` +
|
|
68
|
+
`Refine the call (filter / paginate / flatten) to get a smaller, complete result.]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
function trimOrphanToolResults(history: Message[]): Message[] {
|
|
56
72
|
let i = 0;
|
|
57
73
|
while (i < history.length) {
|
|
@@ -73,6 +89,7 @@ export interface AgentEvent {
|
|
|
73
89
|
| 'message_saved' // a full message persisted (assistant or tool-results carrier)
|
|
74
90
|
| 'memory_status' // pinned/blocks/hits snapshot from Temper for the UI strip
|
|
75
91
|
| 'turn_done' // loop finished
|
|
92
|
+
| 'watch_status' // ambient background-watch progress (status chip, NOT a message)
|
|
76
93
|
| 'error'; // unrecoverable
|
|
77
94
|
message_id?: string;
|
|
78
95
|
data?: any;
|
|
@@ -392,6 +409,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
392
409
|
const memHandlers: Record<string, BuiltinHandler> = {};
|
|
393
410
|
for (const t of memTools) memHandlers[t.def.name] = t.handle;
|
|
394
411
|
|
|
412
|
+
// start_watch — LLM-driven background watch (always available). Bound
|
|
413
|
+
// to this session so completion reports back here.
|
|
414
|
+
const watchTool = buildStartWatchTool(args.sessionId);
|
|
415
|
+
memHandlers[watchTool.def.name] = watchTool.handle;
|
|
416
|
+
|
|
395
417
|
if (memStore.enabled) {
|
|
396
418
|
// Inspector strip (memory_status event) wants the full inventory —
|
|
397
419
|
// keep its own listBlocks call. The prompt-injection text comes
|
|
@@ -466,6 +488,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
466
488
|
const builtinDefsAll = [
|
|
467
489
|
...BUILTIN_TOOL_DEFS,
|
|
468
490
|
...memTools.map((m) => m.def),
|
|
491
|
+
watchTool.def,
|
|
469
492
|
];
|
|
470
493
|
const allTools: LlmTool[] = [
|
|
471
494
|
...builtinDefsAll.map((t) => ({
|
|
@@ -500,6 +523,13 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
500
523
|
const history = trimOrphanToolResults(
|
|
501
524
|
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, HISTORY_TOKEN_BUDGET, estimateTokens),
|
|
502
525
|
);
|
|
526
|
+
// Belt-and-suspenders: tool_result truncation should keep a complete
|
|
527
|
+
// pair in-window, but if history is somehow empty, fail clearly
|
|
528
|
+
// instead of letting the provider throw "messages must not be empty".
|
|
529
|
+
if (history.length === 0) {
|
|
530
|
+
cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
|
|
531
|
+
return { ok: false, error: 'empty history' };
|
|
532
|
+
}
|
|
503
533
|
|
|
504
534
|
assistantBlocksAccum = [];
|
|
505
535
|
let currentTextBuf = '';
|
|
@@ -543,11 +573,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
543
573
|
const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
544
574
|
const toolResults: ToolResultBlock[] = [];
|
|
545
575
|
for (const t of toolUses) {
|
|
546
|
-
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, memHandlers);
|
|
576
|
+
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
|
|
547
577
|
const block: ToolResultBlock = {
|
|
548
578
|
type: 'tool_result',
|
|
549
579
|
tool_use_id: t.id,
|
|
550
|
-
content: r.content,
|
|
580
|
+
content: truncateToolResult(r.content),
|
|
551
581
|
is_error: r.is_error,
|
|
552
582
|
};
|
|
553
583
|
toolResults.push(block);
|