@aion0/forge 0.10.53 → 0.10.55
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 +3 -3
- package/app/api/cache/route.ts +125 -41
- package/app/api/chat/sessions/[id]/abort/route.ts +14 -0
- package/app/api/chat/sessions/[id]/note/route.ts +16 -0
- package/app/api/files/[...path]/route.ts +94 -0
- package/app/api/scratch/[...path]/route.ts +5 -0
- package/app/chat/page.tsx +237 -36
- package/app/files/[...path]/page.tsx +22 -0
- package/components/Dashboard.tsx +76 -24
- package/components/ScratchViewer.tsx +14 -3
- package/lib/chat/agent-loop.ts +72 -2
- package/lib/chat/link-patterns.ts +28 -5
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +81 -0
- package/lib/chat-standalone.ts +51 -0
- package/lib/help-docs/10-troubleshooting.md +16 -0
- package/lib/help-docs/17-connectors.md +19 -0
- package/lib/help-docs/25-chat-tools.md +125 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/package.json +1 -1
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { buildMemoryContext } from './build-memory-context';
|
|
|
28
28
|
import { buildReferencePromptSection } from './reference-prompt';
|
|
29
29
|
import { buildMemoryTools } from './memory-tools';
|
|
30
30
|
import { buildStartWatchTool } from '../watch/start-watch-tool';
|
|
31
|
+
import { beginTurn, endTurn, isAborted, consumeNotes } from './turn-control';
|
|
31
32
|
import { estimateTokens } from '../memory/token-estimate';
|
|
32
33
|
import {
|
|
33
34
|
listInstalledConnectors,
|
|
@@ -419,9 +420,16 @@ function buildSystemPrompt(
|
|
|
419
420
|
}
|
|
420
421
|
}
|
|
421
422
|
if (builtinDefs.length > 0) {
|
|
423
|
+
// Builtins are always-active (no connector_open gate) — list their
|
|
424
|
+
// FULL description so per-tool rules like "save_tmp_file: output
|
|
425
|
+
// user_message verbatim" / "dispatch_task: ASK before classifying
|
|
426
|
+
// a save as a task" reach the LLM here, not just buried in the
|
|
427
|
+
// tools schema. The connector catalog above can be terse because
|
|
428
|
+
// those tools only become live after connector_open; builtins
|
|
429
|
+
// never go through that gate, so terseness here loses real guidance.
|
|
422
430
|
lines.push('', 'Builtin tools (always available):');
|
|
423
431
|
for (const t of builtinDefs) {
|
|
424
|
-
lines.push(`- ${t.name}: ${t.description
|
|
432
|
+
lines.push(`- ${t.name}: ${t.description}`);
|
|
425
433
|
}
|
|
426
434
|
}
|
|
427
435
|
|
|
@@ -754,10 +762,52 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
754
762
|
let lastStop = '';
|
|
755
763
|
let assistantBlocksAccum: ContentBlock[] = [];
|
|
756
764
|
|
|
765
|
+
// Mark this turn live so the user can abort it / inject notes mid-flight
|
|
766
|
+
// (see turn-control.ts). Cleared in the finally below.
|
|
767
|
+
beginTurn(args.sessionId);
|
|
768
|
+
|
|
757
769
|
try {
|
|
758
770
|
while (iter < MAX_ITERATIONS) {
|
|
759
771
|
iter += 1;
|
|
760
772
|
|
|
773
|
+
// ── User intervention (turn-control) ────────────────────────
|
|
774
|
+
// Abort: stop the loop cleanly at this boundary with a sentinel so
|
|
775
|
+
// the turn doesn't just vanish. (The current in-flight LLM call /
|
|
776
|
+
// tool batch from the previous iteration has already settled here.)
|
|
777
|
+
if (isAborted(args.sessionId)) {
|
|
778
|
+
const stopMsg = appendMessage({
|
|
779
|
+
session_id: args.sessionId,
|
|
780
|
+
role: 'assistant',
|
|
781
|
+
blocks: [{ type: 'text', text: '⏹ Stopped by user.' } as TextBlock],
|
|
782
|
+
});
|
|
783
|
+
cb({ type: 'message_saved', message_id: stopMsg.id, data: stopMsg });
|
|
784
|
+
lastStop = 'aborted';
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
// Supplementary notes: splice any queued user notes in as a user
|
|
788
|
+
// message so THIS iteration's LLM call sees them. Safe after a
|
|
789
|
+
// tool_result message — the adapter emits that as a `tool` message,
|
|
790
|
+
// so a following `user` message doesn't collide on role.
|
|
791
|
+
// Drain notes — each becomes its own user message so the thread
|
|
792
|
+
// shows them in arrival order (matches the optimistic messages the
|
|
793
|
+
// client already rendered when the user hit Send). The first note
|
|
794
|
+
// carries a flag for the model so it knows this is a mid-task
|
|
795
|
+
// redirect, not ambient chat; subsequent notes go raw to avoid
|
|
796
|
+
// cluttering the visible thread.
|
|
797
|
+
const notes = consumeNotes(args.sessionId);
|
|
798
|
+
if (notes.length > 0) {
|
|
799
|
+
const FLAG = '[mid-task interjection — sent WHILE you were running tools. Treat as an authoritative redirect that overrides any plan you announced earlier (count, target, scope). Adjust on the very next step.]';
|
|
800
|
+
for (let i = 0; i < notes.length; i++) {
|
|
801
|
+
const text = i === 0 ? `${FLAG}\n\n${notes[i]}` : notes[i]!;
|
|
802
|
+
const noteMsg = appendMessage({
|
|
803
|
+
session_id: args.sessionId,
|
|
804
|
+
role: 'user',
|
|
805
|
+
blocks: [{ type: 'text', text } as TextBlock],
|
|
806
|
+
});
|
|
807
|
+
cb({ type: 'message_saved', message_id: noteMsg.id, data: noteMsg });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
761
811
|
// ── Recompute open set every iteration ──────────────────────
|
|
762
812
|
// Scan history (since last user text msg) + this turn's accumulated
|
|
763
813
|
// blocks → which connectors are open right now. Then filter tools.
|
|
@@ -885,10 +935,28 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
885
935
|
|
|
886
936
|
if (result.stopReason !== 'tool_use') break;
|
|
887
937
|
|
|
888
|
-
// Execute tool calls
|
|
938
|
+
// Execute tool calls. The LLM can emit several tool_use blocks per
|
|
939
|
+
// iteration (parallel batch). Without an in-batch abort check, a
|
|
940
|
+
// user who clicks Stop after the batch starts has to wait for ALL
|
|
941
|
+
// tools to finish before the loop top-check fires next iter — feels
|
|
942
|
+
// like Stop did nothing. So: between tools, if abort was requested,
|
|
943
|
+
// skip the remaining ones with synthetic "aborted" tool_results
|
|
944
|
+
// (the tool_use/tool_result pairing invariant must hold for the
|
|
945
|
+
// Anthropic API; an orphan tool_use rejects the next call).
|
|
889
946
|
const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
890
947
|
const toolResults: ToolResultBlock[] = [];
|
|
891
948
|
for (const t of toolUses) {
|
|
949
|
+
if (isAborted(args.sessionId)) {
|
|
950
|
+
const block: ToolResultBlock = {
|
|
951
|
+
type: 'tool_result',
|
|
952
|
+
tool_use_id: t.id,
|
|
953
|
+
content: '⏹ Skipped — user requested stop.',
|
|
954
|
+
is_error: true,
|
|
955
|
+
};
|
|
956
|
+
toolResults.push(block);
|
|
957
|
+
cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: { content: block.content, is_error: true } } });
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
892
960
|
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
|
|
893
961
|
const block: ToolResultBlock = {
|
|
894
962
|
type: 'tool_result',
|
|
@@ -960,5 +1028,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
960
1028
|
});
|
|
961
1029
|
cb({ type: 'error', data: { error: msg } });
|
|
962
1030
|
return { ok: false, error: msg };
|
|
1031
|
+
} finally {
|
|
1032
|
+
endTurn(args.sessionId);
|
|
963
1033
|
}
|
|
964
1034
|
}
|
|
@@ -81,17 +81,40 @@ export const LINK_PATTERNS: LinkPattern[] = [
|
|
|
81
81
|
url: 'https://nvd.nist.gov/vuln/detail/{1}',
|
|
82
82
|
label: '{1}',
|
|
83
83
|
},
|
|
84
|
-
// Forge
|
|
85
|
-
// `scratch/foo.md`
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
84
|
+
// Forge-managed files inside <dataDir>/. LLMs emit:
|
|
85
|
+
// • `scratch/foo.md` — legacy task-workspace writes (kept under
|
|
86
|
+
// <dataDir>/scratch/, served by /scratch viewer)
|
|
87
|
+
// • `tmp/foo.md` — chat's save_tmp_file output (<dataDir>/tmp/)
|
|
88
|
+
// • `flows/x.yaml` — pipeline workflow definitions
|
|
89
|
+
// • `prompts/y.yaml` — schedule prompt bodies
|
|
90
|
+
// All four resolve through the /files/<dataDir-path> viewer (rendered
|
|
91
|
+
// markdown + download button), which fetches /api/files for raw bytes.
|
|
92
|
+
// Sensitive top-level items (encrypt key, sqlite DBs, log, token
|
|
93
|
+
// caches) are blocked at the API layer, so safe to be liberal here.
|
|
89
94
|
{
|
|
90
95
|
id: 'scratch-file',
|
|
91
96
|
regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
|
|
92
97
|
url: '/scratch/{1}',
|
|
93
98
|
label: 'scratch/{1}',
|
|
94
99
|
},
|
|
100
|
+
{
|
|
101
|
+
id: 'tmp-file',
|
|
102
|
+
regex: /\btmp\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
|
|
103
|
+
url: '/files/tmp/{1}',
|
|
104
|
+
label: 'tmp/{1}',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'flows-file',
|
|
108
|
+
regex: /\bflows\/([\w\-./]+?\.(?:yaml|yml|json))\b/gi,
|
|
109
|
+
url: '/files/flows/{1}',
|
|
110
|
+
label: 'flows/{1}',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'prompts-file',
|
|
114
|
+
regex: /\bprompts\/([\w\-./]+?\.(?:yaml|yml|md|txt))\b/gi,
|
|
115
|
+
url: '/files/prompts/{1}',
|
|
116
|
+
label: 'prompts/{1}',
|
|
117
|
+
},
|
|
95
118
|
];
|
|
96
119
|
|
|
97
120
|
export interface CompiledPattern {
|
|
@@ -37,6 +37,85 @@ export interface ToolResult {
|
|
|
37
37
|
|
|
38
38
|
export type BuiltinHandler = (input: unknown) => Promise<string>;
|
|
39
39
|
|
|
40
|
+
// ─── Data-dir file helpers ────────────────────────────────
|
|
41
|
+
// resolveTmpPath: scoped to <dataDir>/tmp/ — where save_tmp_file drops
|
|
42
|
+
// its files. Kept separate from scratch/ (the synthetic "scratch
|
|
43
|
+
// project" task workspace) so the cache janitor can wipe tmp/ without
|
|
44
|
+
// disturbing in-progress task workdirs.
|
|
45
|
+
async function resolveTmpPath(rel: string): Promise<string> {
|
|
46
|
+
const { getDataDir } = await import('../dirs');
|
|
47
|
+
const { join, resolve } = await import('node:path');
|
|
48
|
+
const clean = rel.replace(/^\/+/, '');
|
|
49
|
+
if (!clean || clean.includes('..') || clean.includes('\0')) throw new Error('invalid tmp path');
|
|
50
|
+
const tmpRoot = join(getDataDir(), 'tmp');
|
|
51
|
+
const target = resolve(tmpRoot, clean);
|
|
52
|
+
if (!target.startsWith(tmpRoot + '/') && target !== tmpRoot) {
|
|
53
|
+
throw new Error('resolved path escapes tmp dir');
|
|
54
|
+
}
|
|
55
|
+
return target;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// resolveDataPath: scoped to <dataDir>/ — the read/list tools' broader
|
|
59
|
+
// surface. Blocks known sensitive files (encrypt key, enterprise keys,
|
|
60
|
+
// sqlite DBs, server log, anything starting with a dot at the dataDir
|
|
61
|
+
// root). Subpaths under known-safe subdirs (scratch/, flows/, prompts/,
|
|
62
|
+
// connectors/, etc.) are accepted as-is. The agent can use this to
|
|
63
|
+
// inspect its OWN config — but secrets stay invisible.
|
|
64
|
+
function isSensitiveDataPath(target: string, dataRoot: string): string | null {
|
|
65
|
+
if (target === dataRoot) return null;
|
|
66
|
+
const rel = target.slice(dataRoot.length + 1); // drop "<root>/"
|
|
67
|
+
const top = rel.split('/')[0] || '';
|
|
68
|
+
// Top-level dotfiles (.encrypt-key, .enterprise-keys.json, …) — secrets.
|
|
69
|
+
if (top.startsWith('.')) return `top-level dotfile "${top}" is sensitive (encryption keys, session state)`;
|
|
70
|
+
// SQLite databases are binary, large, and contain raw chat / task history.
|
|
71
|
+
if (/\.(db|db-wal|db-shm)$/i.test(top)) return `sqlite database "${top}" — read via Forge APIs instead`;
|
|
72
|
+
// forge.log / forge.log.old — large + may contain tokens that leaked into errors.
|
|
73
|
+
if (/^forge\.log/i.test(top)) return 'forge.log is server diagnostics — ask the user to grep it directly';
|
|
74
|
+
// Token caches at root.
|
|
75
|
+
if (/-tokens\.json$/i.test(top)) return `"${top}" stores auth tokens — sensitive`;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
async function resolveDataPath(rel: string): Promise<string> {
|
|
79
|
+
const { getDataDir } = await import('../dirs');
|
|
80
|
+
const { resolve } = await import('node:path');
|
|
81
|
+
const clean = String(rel || '').replace(/^\/+/, '');
|
|
82
|
+
if (clean.includes('..') || clean.includes('\0')) throw new Error('invalid path');
|
|
83
|
+
const dataRoot = getDataDir();
|
|
84
|
+
const target = resolve(dataRoot, clean);
|
|
85
|
+
if (!target.startsWith(dataRoot + '/') && target !== dataRoot) {
|
|
86
|
+
throw new Error('resolved path escapes data dir');
|
|
87
|
+
}
|
|
88
|
+
const blocked = isSensitiveDataPath(target, dataRoot);
|
|
89
|
+
if (blocked) throw new Error(`refused: ${blocked}`);
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A connector arg whose WHOLE value is one of these forms is a reference
|
|
94
|
+
// to a Forge-managed file. Path inside the prefix is dataDir-relative —
|
|
95
|
+
// e.g. "scratch://tmp/foo.md", "scratch://flows/x.yaml". The "scratch:"
|
|
96
|
+
// prefix is historical (originally only pointed at <dataDir>/scratch/);
|
|
97
|
+
// kept for compatibility.
|
|
98
|
+
const SCRATCH_REF_RE = /^(?:scratch:\/\/|scratch:|\/api\/scratch\/)(.+)$/;
|
|
99
|
+
|
|
100
|
+
// Replace any `scratch://<file>` arg with the file's base64 content,
|
|
101
|
+
// server-side. This is the whole point: the LLM can hand a connector a
|
|
102
|
+
// saved file (e.g. onedrive.upload_file content_base64) without ever
|
|
103
|
+
// hand-encoding it — hand-encoding large/non-ASCII content is unreliable
|
|
104
|
+
// and is exactly what wedged the OneDrive upload in an endless retry loop.
|
|
105
|
+
async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<void> {
|
|
106
|
+
const { readFile } = await import('node:fs/promises');
|
|
107
|
+
for (const [k, v] of Object.entries(args)) {
|
|
108
|
+
if (typeof v !== 'string') continue;
|
|
109
|
+
const m = v.match(SCRATCH_REF_RE);
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
// Path inside the ref is dataDir-relative. Goes through the same
|
|
112
|
+
// sensitive-file guard as read_forge_file (no leaking encrypt keys
|
|
113
|
+
// / DBs into a connector upload).
|
|
114
|
+
const buf = await readFile(await resolveDataPath(m[1])); // ENOENT → tool error
|
|
115
|
+
args[k] = buf.toString('base64');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
40
119
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
41
120
|
get_current_time: async () => new Date().toISOString(),
|
|
42
121
|
|
|
@@ -310,31 +389,144 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
310
389
|
// gets a download link in the response. Prefer this over dispatch_task for
|
|
311
390
|
// "save / create / export a file" asks — the LLM already has the content
|
|
312
391
|
// in context, no need to spawn a CLI task that may write elsewhere.
|
|
313
|
-
|
|
392
|
+
save_tmp_file: async (input) => {
|
|
314
393
|
const params = (input as { filename?: string; content?: string } | undefined) || {};
|
|
315
394
|
const filename = (params.filename || '').trim();
|
|
316
395
|
const content = params.content == null ? '' : String(params.content);
|
|
317
396
|
if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
|
|
318
|
-
|
|
319
|
-
|
|
397
|
+
// Bare filename only — no slashes anywhere. The cache janitor
|
|
398
|
+
// (lib/scratch-cleanup.ts) only sweeps top-level files in tmp/;
|
|
399
|
+
// allowing nested writes would create files that never expire.
|
|
400
|
+
// Also keeps the path returned in `path: "tmp/<filename>"` simple.
|
|
401
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\') || filename.includes('\0')) {
|
|
402
|
+
return JSON.stringify({ ok: false, error: 'filename must be a bare relative name (no slashes, no ".." — use date-stamped flat names like "report-2026-06-09.md")' });
|
|
320
403
|
}
|
|
321
|
-
|
|
322
|
-
|
|
404
|
+
// Land under <dataDir>/tmp/ — cache-managed location the user can
|
|
405
|
+
// sweep via "forge clean cache" / the user-menu Cache panel. Keeps
|
|
406
|
+
// <dataDir>/scratch/ for the synthetic "scratch project" workspace
|
|
407
|
+
// (where dispatched tasks live + edit their own files).
|
|
408
|
+
let target: string;
|
|
409
|
+
try { target = await resolveTmpPath(filename); }
|
|
410
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
323
411
|
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
324
|
-
const scratchRoot = join(getDataDir(), 'scratch');
|
|
325
|
-
const target = resolve(scratchRoot, filename);
|
|
326
|
-
if (!target.startsWith(scratchRoot + '/') && target !== scratchRoot) {
|
|
327
|
-
return JSON.stringify({ ok: false, error: 'resolved path escapes scratch dir' });
|
|
328
|
-
}
|
|
329
412
|
const { dirname } = await import('node:path');
|
|
413
|
+
// Bare filename → dirname is always <dataDir>/tmp/. mkdir -p idempotent.
|
|
330
414
|
await mkdir(dirname(target), { recursive: true });
|
|
331
415
|
await writeFile(target, content, 'utf8');
|
|
416
|
+
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
417
|
+
const fileUrl = `file://${target}`;
|
|
418
|
+
// Pre-format the user-facing message. Tell agent to OUTPUT this
|
|
419
|
+
// verbatim — agents follow "echo this string" instructions far
|
|
420
|
+
// more reliably than abstract "include the URL" hints. Past
|
|
421
|
+
// attempts with hint-only kept producing prose that lost the
|
|
422
|
+
// file:// URL or wrapped it in markdown the chat UI couldn't
|
|
423
|
+
// render. This sidesteps the entire link-pattern question.
|
|
424
|
+
const userMessage = `✓ Saved (${sizeBytes} bytes) — ${fileUrl}`;
|
|
425
|
+
return JSON.stringify({
|
|
426
|
+
ok: true,
|
|
427
|
+
path: `tmp/${filename}`,
|
|
428
|
+
local_path: target,
|
|
429
|
+
file_url: fileUrl,
|
|
430
|
+
bytes: sizeBytes,
|
|
431
|
+
user_message: userMessage,
|
|
432
|
+
hint: 'OUTPUT THE `user_message` FIELD VERBATIM as your reply to the user — copy the exact characters, no markdown wrapping, no rephrasing, no shortening, no extra prose. That string contains the file:// URL Chrome can open directly on localhost (and that the user can paste into Finder/Explorer). For follow-up asks like "give me the link" / "where is it" / "提供链接" / "再给我一次" → output the same file:// URL again (' + fileUrl + '). To UPLOAD this file to a connector (e.g. onedrive.upload_file), pass "scratch://tmp/' + filename + '" as the content_base64 arg — Forge base64-encodes server-side; do NOT hand-encode. To read it back later, use read_forge_file with filename:"tmp/' + filename + '". tmp/ is auto-swept by the cache janitor.',
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
// Read any Forge-managed file under <dataDir>/ — tmp/, scratch/,
|
|
437
|
+
// flows/, prompts/, connectors/, etc. Sensitive files (encrypt key,
|
|
438
|
+
// sqlite DBs, log, *-tokens.json) are refused. Text returns decoded
|
|
439
|
+
// text (capped 256KB); pass as_base64:true for binary. To upload a
|
|
440
|
+
// file to a connector, prefer passing "scratch://<dataDir-rel>" as
|
|
441
|
+
// the arg — base64-encoded server-side; no bytes through the model.
|
|
442
|
+
read_forge_file: async (input) => {
|
|
443
|
+
const params = (input as { filename?: string; as_base64?: boolean } | undefined) || {};
|
|
444
|
+
// Normalize: strip the scratch:// / /api/scratch/ prefixes the
|
|
445
|
+
// agent might have copied from save_tmp_file's response. The
|
|
446
|
+
// remaining string is a dataDir-relative path (e.g. "tmp/foo.md",
|
|
447
|
+
// "flows/x.yaml", "prompts/y.yaml").
|
|
448
|
+
const filename = (params.filename || '').trim().replace(/^(?:scratch:\/\/|scratch:|\/api\/scratch\/)/, '');
|
|
449
|
+
if (!filename) return JSON.stringify({ ok: false, error: 'filename is required' });
|
|
450
|
+
let target: string;
|
|
451
|
+
try { target = await resolveDataPath(filename); }
|
|
452
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
453
|
+
const { readFile } = await import('node:fs/promises');
|
|
454
|
+
let buf: Buffer;
|
|
455
|
+
try { buf = await readFile(target); }
|
|
456
|
+
catch { return JSON.stringify({ ok: false, error: `Forge file not found: ${filename}` }); }
|
|
457
|
+
if (params.as_base64) {
|
|
458
|
+
return JSON.stringify({
|
|
459
|
+
ok: true, path: filename,
|
|
460
|
+
local_path: target, file_url: `file://${target}`,
|
|
461
|
+
encoding: 'base64', size_bytes: buf.length, content: buf.toString('base64'),
|
|
462
|
+
// "scratch://" is the connector-ref protocol name (historical —
|
|
463
|
+
// not the scratch/ dir). Path after it is dataDir-relative.
|
|
464
|
+
note: `To upload to a connector, prefer passing "scratch://${filename}" directly as the connector arg — Forge resolves + base64-encodes it server-side, no need to round-trip these bytes through the model.`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const MAX = 256 * 1024;
|
|
468
|
+
const truncated = buf.length > MAX;
|
|
469
|
+
return JSON.stringify({
|
|
470
|
+
ok: true, path: filename,
|
|
471
|
+
local_path: target, file_url: `file://${target}`,
|
|
472
|
+
encoding: 'utf-8', size_bytes: buf.length, truncated,
|
|
473
|
+
content: buf.subarray(0, MAX).toString('utf8'),
|
|
474
|
+
...(truncated ? { note: `content truncated to ${MAX} bytes — use as_base64 for the full file` } : {}),
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
// List files anywhere under <dataDir>/ (Forge's own data dir).
|
|
479
|
+
// Replaces the "dispatch a task just to `ls`" workaround. The `dir`
|
|
480
|
+
// param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
|
|
481
|
+
// "connectors" / "prompts" / etc. No dir → lists dataDir root.
|
|
482
|
+
// Sensitive items (encrypt key, sqlite DBs, log, *-tokens.json at
|
|
483
|
+
// root) are skipped — see resolveDataPath.
|
|
484
|
+
list_forge_files: async (input) => {
|
|
485
|
+
const params = (input as { dir?: string; ext?: string; limit?: number } | undefined) || {};
|
|
486
|
+
const sub = String(params.dir || '').trim().replace(/^\/+|\/+$/g, '');
|
|
487
|
+
const ext = String(params.ext || '').trim().toLowerCase();
|
|
488
|
+
const limit = Math.max(1, Math.min(500, Number(params.limit) || 200));
|
|
489
|
+
const { getDataDir } = await import('../dirs');
|
|
490
|
+
const { join, relative } = await import('node:path');
|
|
491
|
+
const { readdir, stat } = await import('node:fs/promises');
|
|
492
|
+
const dataRoot = getDataDir();
|
|
493
|
+
let target: string;
|
|
494
|
+
try { target = sub ? await resolveDataPath(sub) : dataRoot; }
|
|
495
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
496
|
+
let dirents;
|
|
497
|
+
try { dirents = await readdir(target, { withFileTypes: true }); }
|
|
498
|
+
catch (e) { return JSON.stringify({ ok: false, error: `cannot list ${sub || 'data/'}: ${(e as Error).message}` }); }
|
|
499
|
+
const entries: Array<{ path: string; kind: 'file' | 'dir'; size?: number; mtime?: string; file_url?: string }> = [];
|
|
500
|
+
for (const d of dirents) {
|
|
501
|
+
const full = join(target, d.name);
|
|
502
|
+
// Apply the same sensitive-file filter to entries we list — so the
|
|
503
|
+
// agent doesn't even see .encrypt-key / *.db etc.
|
|
504
|
+
if (isSensitiveDataPath(full, dataRoot)) continue;
|
|
505
|
+
if (ext && d.isFile() && !d.name.toLowerCase().endsWith(ext)) continue;
|
|
506
|
+
let size: number | undefined;
|
|
507
|
+
let mtime: string | undefined;
|
|
508
|
+
if (d.isFile()) {
|
|
509
|
+
try { const s = await stat(full); size = s.size; mtime = s.mtime.toISOString(); } catch {}
|
|
510
|
+
}
|
|
511
|
+
entries.push({
|
|
512
|
+
path: relative(dataRoot, full),
|
|
513
|
+
kind: d.isDirectory() ? 'dir' : 'file',
|
|
514
|
+
...(size !== undefined ? { size } : {}),
|
|
515
|
+
...(mtime ? { mtime } : {}),
|
|
516
|
+
// file:// URL the user can paste into Chrome (works on localhost).
|
|
517
|
+
// Provide on dirs too — opening a dir in Chrome shows the listing.
|
|
518
|
+
file_url: `file://${full}`,
|
|
519
|
+
});
|
|
520
|
+
if (entries.length >= limit) break;
|
|
521
|
+
}
|
|
522
|
+
entries.sort((a, b) => (b.mtime || '').localeCompare(a.mtime || '') || a.path.localeCompare(b.path));
|
|
332
523
|
return JSON.stringify({
|
|
333
524
|
ok: true,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
525
|
+
root: relative(dataRoot, target) || '.',
|
|
526
|
+
local_root: target,
|
|
527
|
+
count: entries.length,
|
|
528
|
+
entries,
|
|
529
|
+
hint: 'When the user asks to show / list files, surface each entry\'s `path` (dataDir-relative, e.g. "tmp/foo.md") and `file_url` (file://… — opens in Chrome on localhost). Use read_forge_file with the same `path` to inspect content. PREFER this over dispatch_task with `ls`.',
|
|
338
530
|
});
|
|
339
531
|
},
|
|
340
532
|
|
|
@@ -365,6 +557,23 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
365
557
|
});
|
|
366
558
|
},
|
|
367
559
|
|
|
560
|
+
// Stop a still-running dispatched task. Use when the user says "停止
|
|
561
|
+
// / cancel / kill the task" — much better than telling them to open
|
|
562
|
+
// the Tasks UI. No-op (returns terminal:true) if the task already
|
|
563
|
+
// finished, so it's safe to call without checking status first.
|
|
564
|
+
cancel_task: async (input) => {
|
|
565
|
+
const params = (input as { task_id?: string } | undefined) || {};
|
|
566
|
+
if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required' });
|
|
567
|
+
const { getTask, cancelTask } = await import('../task-manager');
|
|
568
|
+
const task = getTask(params.task_id);
|
|
569
|
+
if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
|
|
570
|
+
if (task.status !== 'running' && task.status !== 'queued') {
|
|
571
|
+
return JSON.stringify({ ok: true, task_id: task.id, status: task.status, terminal: true, note: 'already terminal — no cancel needed' });
|
|
572
|
+
}
|
|
573
|
+
const cancelled = cancelTask(params.task_id);
|
|
574
|
+
return JSON.stringify({ ok: cancelled, task_id: task.id, status: cancelled ? 'cancelled' : task.status, terminal: cancelled });
|
|
575
|
+
},
|
|
576
|
+
|
|
368
577
|
// Companion to dispatch_task — read a task's status + result. Returns JSON
|
|
369
578
|
// so start_watch can poll via done_path="terminal" or done_match
|
|
370
579
|
// {path:"status", equals:"done"}.
|
|
@@ -630,7 +839,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
630
839
|
},
|
|
631
840
|
{
|
|
632
841
|
name: 'dispatch_task',
|
|
633
|
-
description: 'Dispatch a one-shot background Claude task in a Forge project.
|
|
842
|
+
description: 'Dispatch a one-shot background Claude CLI task in a Forge project. EXPENSIVE — spawns a fresh Claude subprocess that reads/edits code in the target project. Use ONLY for genuine codebase work: "analyze X repo and write findings", "run the test suite and summarize", "fix bug in <project>", "refactor module Y". \n\nDO NOT use for: \n • Saving a file with content YOU already have → use save_tmp_file. \n • Reading a Forge-owned file (tmp/scratch/flows/prompts/...) → use read_forge_file. \n • Listing files in <dataDir>/ → use list_forge_files. \n • Running a pipeline → use trigger_pipeline. \n • Inspecting a saved task → use get_task_status. \n\nFor "create / write / save a file with this content" the right tool is ALWAYS save_tmp_file — the LLM has the content, no CLI subprocess needed. \n\nIf the user\'s ask is ambiguous (might be a quick save vs a real codebase task), STOP and ask before dispatching — a user reporting "I just wanted a file" after seeing a task spawn is a clear signal you misclassified. \n\nReturns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants completion notification, follow the hint — call start_watch on get_task_status and STOP polling in this conversation.',
|
|
634
843
|
input_schema: {
|
|
635
844
|
type: 'object',
|
|
636
845
|
properties: {
|
|
@@ -651,8 +860,8 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
651
860
|
},
|
|
652
861
|
},
|
|
653
862
|
{
|
|
654
|
-
name: '
|
|
655
|
-
description: 'Save text content to
|
|
863
|
+
name: 'save_tmp_file',
|
|
864
|
+
description: 'Save text content to <dataDir>/tmp/<filename>. USE THIS — never dispatch_task — when the user says "save this to a file", "create a file with X", "export the results", "give me a downloadable copy". The LLM already has the content; no CLI task needed. Returns `file_url` (file://… that opens directly in Chrome on localhost) plus `path` ("tmp/<filename>") + `local_path`. tmp/ is cache-managed and gets swept by the cache janitor / "forge clean cache" — tell the user it\'s ephemeral. To UPLOAD this file to a connector (e.g. onedrive.upload_file content_base64), pass "scratch://tmp/<filename>" as the arg — Forge base64-encodes server-side; do NOT hand-encode. To read it back: read_forge_file with filename:"tmp/<filename>". Filename must be a bare relative name (no .. or /).',
|
|
656
865
|
input_schema: {
|
|
657
866
|
type: 'object',
|
|
658
867
|
properties: {
|
|
@@ -668,6 +877,41 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
668
877
|
required: ['filename', 'content'],
|
|
669
878
|
},
|
|
670
879
|
},
|
|
880
|
+
{
|
|
881
|
+
name: 'read_forge_file',
|
|
882
|
+
description: 'Read any file Forge manages under <dataDir>/ — tmp/ (chat-saved), scratch/ (task workspaces), flows/, prompts/, connectors/, etc. PREFER THIS over dispatch_task when the user wants to inspect a Forge-owned file. filename is a dataDir-relative path: "tmp/report.md", "flows/x.yaml", "connectors/onedrive/manifest.yaml". Sensitive files (encrypt key, sqlite DBs, server log, *-tokens.json at root) are refused. Text returns decoded UTF-8 (capped 256KB); pass as_base64:true for binary (pdf, images, zip). To UPLOAD to a connector, prefer "scratch://<dataDir-rel>" arg — bytes never traverse the model.',
|
|
883
|
+
input_schema: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
properties: {
|
|
886
|
+
filename: { type: 'string', description: 'dataDir-relative path, e.g. "tmp/report.md", "flows/mantis-bug-fix.yaml", "connectors/mantis/manifest.yaml".' },
|
|
887
|
+
as_base64: { type: 'boolean', description: 'Return raw bytes base64-encoded instead of decoded UTF-8 text. Use for binary files.' },
|
|
888
|
+
},
|
|
889
|
+
required: ['filename'],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
name: 'list_forge_files',
|
|
894
|
+
description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
|
|
895
|
+
input_schema: {
|
|
896
|
+
type: 'object',
|
|
897
|
+
properties: {
|
|
898
|
+
dir: { type: 'string', description: 'dataDir-relative directory. Examples: "tmp", "scratch", "flows", "prompts", "connectors/mantis". Omit to list dataDir root.' },
|
|
899
|
+
ext: { type: 'string', description: 'Optional file-extension filter (e.g. ".md", ".yaml"). Lowercase match on suffix.' },
|
|
900
|
+
limit: { type: 'number', description: 'Max entries (default 200, capped at 500).' },
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
name: 'cancel_task',
|
|
906
|
+
description: 'Cancel a still-running dispatched task by id. Use when the user says "停止 / cancel / kill the task" — much cleaner than telling them to open the Tasks UI. Safe to call on an already-terminal task (returns ok with note). task_id comes from dispatch_task\'s response.',
|
|
907
|
+
input_schema: {
|
|
908
|
+
type: 'object',
|
|
909
|
+
properties: {
|
|
910
|
+
task_id: { type: 'string', description: 'Task id from dispatch_task.' },
|
|
911
|
+
},
|
|
912
|
+
required: ['task_id'],
|
|
913
|
+
},
|
|
914
|
+
},
|
|
671
915
|
{
|
|
672
916
|
name: 'get_task_status',
|
|
673
917
|
description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
|
|
@@ -1090,6 +1334,15 @@ export async function dispatchTool(
|
|
|
1090
1334
|
const cfgGap = preflightConnectorSettings(def, effectiveSettings);
|
|
1091
1335
|
if (cfgGap) return { content: cfgGap, is_error: true };
|
|
1092
1336
|
|
|
1337
|
+
// Resolve any `scratch://<file>` arg into the file's base64 content,
|
|
1338
|
+
// server-side — so the LLM can hand a saved file to a connector
|
|
1339
|
+
// (e.g. onedrive.upload_file content_base64) without hand-encoding it.
|
|
1340
|
+
try {
|
|
1341
|
+
await resolveScratchRefsInArgs(argInput);
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
return { content: `scratch reference failed: ${(e as Error).message}`, is_error: true };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1093
1346
|
try {
|
|
1094
1347
|
let result: ToolResult;
|
|
1095
1348
|
switch (protocol) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session control channel for an in-flight agent turn.
|
|
3
|
+
*
|
|
4
|
+
* A tool-call loop (agent-loop.ts) can otherwise run unattended for up to
|
|
5
|
+
* MAX_ITERATIONS with no way for the user to intervene — which is exactly
|
|
6
|
+
* how a doomed task melts an instance. This gives two levers, both scoped
|
|
7
|
+
* to a CURRENTLY RUNNING turn:
|
|
8
|
+
* • abort — break the loop cleanly at the next iteration boundary
|
|
9
|
+
* • notes — supplementary text the agent picks up on its next iteration
|
|
10
|
+
*
|
|
11
|
+
* State is in-process (chat-standalone owns the loop) and ephemeral: it is
|
|
12
|
+
* reset when the turn begins and cleared when it ends. Both levers no-op
|
|
13
|
+
* when no turn is running, so the caller can tell the user to just send a
|
|
14
|
+
* normal message instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface TurnControl {
|
|
18
|
+
running: boolean;
|
|
19
|
+
aborted: boolean;
|
|
20
|
+
notes: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const controls = new Map<string, TurnControl>();
|
|
24
|
+
|
|
25
|
+
function get(sessionId: string): TurnControl {
|
|
26
|
+
let c = controls.get(sessionId);
|
|
27
|
+
if (!c) {
|
|
28
|
+
c = { running: false, aborted: false, notes: [] };
|
|
29
|
+
controls.set(sessionId, c);
|
|
30
|
+
}
|
|
31
|
+
return c;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Mark a turn as live. Resets abort + drains stale notes from any prior turn. */
|
|
35
|
+
export function beginTurn(sessionId: string): void {
|
|
36
|
+
const c = get(sessionId);
|
|
37
|
+
c.running = true;
|
|
38
|
+
c.aborted = false;
|
|
39
|
+
c.notes = [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Turn finished (normally or via abort) — clear all transient state. */
|
|
43
|
+
export function endTurn(sessionId: string): void {
|
|
44
|
+
const c = get(sessionId);
|
|
45
|
+
c.running = false;
|
|
46
|
+
c.aborted = false;
|
|
47
|
+
c.notes = [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** User asked to stop. Returns false if no turn is running (nothing to abort). */
|
|
51
|
+
export function requestAbort(sessionId: string): boolean {
|
|
52
|
+
const c = get(sessionId);
|
|
53
|
+
if (!c.running) return false;
|
|
54
|
+
c.aborted = true;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isAborted(sessionId: string): boolean {
|
|
59
|
+
return get(sessionId).aborted;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isTurnRunning(sessionId: string): boolean {
|
|
63
|
+
return get(sessionId).running;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Queue supplementary info for the running turn. Returns false if idle. */
|
|
67
|
+
export function addNote(sessionId: string, text: string): boolean {
|
|
68
|
+
const c = get(sessionId);
|
|
69
|
+
if (!c.running) return false;
|
|
70
|
+
c.notes.push(text);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Take + clear the queued notes (called by the loop each iteration). */
|
|
75
|
+
export function consumeNotes(sessionId: string): string[] {
|
|
76
|
+
const c = get(sessionId);
|
|
77
|
+
if (c.notes.length === 0) return [];
|
|
78
|
+
const out = c.notes;
|
|
79
|
+
c.notes = [];
|
|
80
|
+
return out;
|
|
81
|
+
}
|