@aion0/forge 0.8.4 → 0.8.6
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 +4 -8
- package/app/api/agents/route.ts +11 -1
- package/app/api/jobs/preview/route.ts +54 -5
- package/app/api/jobs/recipes/route.ts +59 -0
- package/app/api/skills/install-local/route.ts +54 -1
- package/app/api/workflows/marketplace/route.ts +52 -0
- package/bin/forge-server.mjs +21 -0
- package/components/PipelineView.tsx +255 -7
- package/components/SettingsModal.tsx +45 -10
- package/components/SkillsPanel.tsx +178 -22
- package/components/WorkspaceView.tsx +3 -1
- package/install.sh +28 -0
- package/lib/agents/index.ts +6 -1
- package/lib/chat/agent-loop.ts +37 -3
- package/lib/chat/llm/anthropic.ts +22 -4
- package/lib/chat/protocols/http.ts +46 -2
- package/lib/chat/tool-dispatcher.ts +21 -3
- package/lib/jobs/recipes.ts +247 -0
- package/lib/jobs/scheduler.ts +17 -2
- package/lib/pipeline.ts +5 -610
- package/lib/settings.ts +6 -0
- package/lib/workflow-marketplace.ts +287 -0
- package/package.json +1 -1
|
@@ -142,7 +142,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
142
142
|
const [syncing, setSyncing] = useState(false);
|
|
143
143
|
const [loading, setLoading] = useState(true);
|
|
144
144
|
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
145
|
-
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts'>('all');
|
|
145
|
+
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('all');
|
|
146
146
|
const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
|
|
147
147
|
// Rules (CLAUDE.md templates)
|
|
148
148
|
const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
|
|
@@ -174,8 +174,15 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
174
174
|
setSkills(data.skills || []);
|
|
175
175
|
setProjects(data.projects || []);
|
|
176
176
|
const localData = await localRes.json();
|
|
177
|
-
//
|
|
178
|
-
|
|
177
|
+
// Hide filesystem items that match a *synced* registry row (those
|
|
178
|
+
// belong to the registry list). Uploaded items (source='local')
|
|
179
|
+
// are intentionally not in this set so they appear under "Local"
|
|
180
|
+
// — that's where users expect to find what they just uploaded.
|
|
181
|
+
const registryNames = new Set(
|
|
182
|
+
(data.skills || [])
|
|
183
|
+
.filter((s: any) => s.source !== 'local')
|
|
184
|
+
.map((s: any) => s.name)
|
|
185
|
+
);
|
|
179
186
|
setLocalItems((localData.items || []).filter((i: any) => !registryNames.has(i.name)));
|
|
180
187
|
} catch {}
|
|
181
188
|
setLoading(false);
|
|
@@ -247,6 +254,17 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
247
254
|
try {
|
|
248
255
|
const fd = new FormData();
|
|
249
256
|
fd.append('file', file);
|
|
257
|
+
// Use the upload filename as the default install name. Without
|
|
258
|
+
// this, the server falls back to frontmatter / wrapper-dir name,
|
|
259
|
+
// which can silently clobber an unrelated skill (e.g. uploading
|
|
260
|
+
// prelude-nac.zip whose wrapper dir is `prelude/` would overwrite
|
|
261
|
+
// the registry-synced `prelude`).
|
|
262
|
+
const baseName = file.name
|
|
263
|
+
.replace(/\.(md|zip)$/i, '')
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
266
|
+
.replace(/^-+|-+$/g, '');
|
|
267
|
+
if (/^[a-z0-9][a-z0-9_-]*$/.test(baseName)) fd.append('name', baseName);
|
|
250
268
|
const r = await fetch('/api/skills/install-local', { method: 'POST', body: fd });
|
|
251
269
|
const j = await r.json();
|
|
252
270
|
if (!r.ok || j.ok === false) {
|
|
@@ -254,6 +272,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
254
272
|
return;
|
|
255
273
|
}
|
|
256
274
|
await fetchSkills();
|
|
275
|
+
alert(`Uploaded as "${j.name}" → ${j.target}`);
|
|
257
276
|
} catch (e) {
|
|
258
277
|
alert(`Upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
259
278
|
} finally { setUploading(false); }
|
|
@@ -352,6 +371,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
352
371
|
// Filter by project, type, and search
|
|
353
372
|
const q = searchQuery.toLowerCase();
|
|
354
373
|
const filtered = (typeFilter === 'local' ? [] : skills
|
|
374
|
+
.filter(s => s.source !== 'local') // local-uploaded skills render under the Local section instead
|
|
355
375
|
.filter(s => projectFilter ? (s.installedGlobal || s.installedProjects.includes(projectFilter)) : true)
|
|
356
376
|
.filter(s => typeFilter === 'all' ? true : s.type === typeFilter)
|
|
357
377
|
.filter(s => !q || s.name.toLowerCase().includes(q) || s.displayName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
|
|
@@ -394,21 +414,31 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
394
414
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
395
415
|
<div className="flex items-center gap-2">
|
|
396
416
|
<span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
>
|
|
408
|
-
|
|
409
|
-
</
|
|
410
|
-
|
|
411
|
-
|
|
417
|
+
{/* Grouped category dropdown — replaces the long inline tab bar.
|
|
418
|
+
Native <optgroup> gives free keyboard nav + a coherent layout
|
|
419
|
+
regardless of category count. */}
|
|
420
|
+
<select
|
|
421
|
+
value={typeFilter}
|
|
422
|
+
onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)}
|
|
423
|
+
className="text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
424
|
+
>
|
|
425
|
+
<optgroup label="Catalog">
|
|
426
|
+
<option value="all">All ({skills.length})</option>
|
|
427
|
+
<option value="skill">Skills ({skillCount})</option>
|
|
428
|
+
<option value="command">Commands ({commandCount})</option>
|
|
429
|
+
<option value="local">Local ({localCount})</option>
|
|
430
|
+
<option value="rules">Rules</option>
|
|
431
|
+
</optgroup>
|
|
432
|
+
<optgroup label="Extensions">
|
|
433
|
+
<option value="plugins">Plugins</option>
|
|
434
|
+
<option value="connectors">Connectors</option>
|
|
435
|
+
<option value="crafts">Crafts</option>
|
|
436
|
+
</optgroup>
|
|
437
|
+
<optgroup label="Templates">
|
|
438
|
+
<option value="recipes">Recipes</option>
|
|
439
|
+
<option value="pipelines">Pipelines</option>
|
|
440
|
+
</optgroup>
|
|
441
|
+
</select>
|
|
412
442
|
</div>
|
|
413
443
|
<span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
|
|
414
444
|
<input
|
|
@@ -426,7 +456,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
426
456
|
type="button"
|
|
427
457
|
onClick={() => skillUploadRef.current?.click()}
|
|
428
458
|
disabled={uploading}
|
|
429
|
-
title="Upload
|
|
459
|
+
title="Upload your own skill — SKILL.md or a .zip bundle. Stored locally, not synced from the marketplace."
|
|
430
460
|
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
431
461
|
>
|
|
432
462
|
{uploading ? 'Uploading…' : '+ Upload'}
|
|
@@ -450,7 +480,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
450
480
|
/>
|
|
451
481
|
</div>}
|
|
452
482
|
|
|
453
|
-
{typeFilter === 'rules' || typeFilter === 'plugins' || typeFilter === 'connectors' || typeFilter === 'crafts' ? null : skills.length === 0 ? (
|
|
483
|
+
{typeFilter === 'rules' || typeFilter === 'plugins' || typeFilter === 'connectors' || typeFilter === 'crafts' || typeFilter === 'recipes' || typeFilter === 'pipelines' ? null : skills.length === 0 ? (
|
|
454
484
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
455
485
|
<p className="text-xs">No skills yet</p>
|
|
456
486
|
<button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
|
|
@@ -501,8 +531,10 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
501
531
|
</div>
|
|
502
532
|
);
|
|
503
533
|
})}
|
|
504
|
-
{/* Local items — collapsible by scope group
|
|
505
|
-
|
|
534
|
+
{/* Local items — collapsible by scope group. Visible on
|
|
535
|
+
All/Local AND on Skills/Commands so uploaded items show
|
|
536
|
+
up under their own type tab too. */}
|
|
537
|
+
{(typeFilter === 'all' || typeFilter === 'local' || typeFilter === 'skill' || typeFilter === 'command') && filteredLocal.length > 0 && (
|
|
506
538
|
<>
|
|
507
539
|
{/* Local section header — collapsible */}
|
|
508
540
|
{typeFilter !== 'local' && (
|
|
@@ -1041,6 +1073,130 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
1041
1073
|
<CraftsMarketplacePanelLazy searchQuery={searchQuery} />
|
|
1042
1074
|
</Suspense>
|
|
1043
1075
|
)}
|
|
1076
|
+
|
|
1077
|
+
{/* Recipes / Pipelines — read-only marketplace browser. Actual
|
|
1078
|
+
use lives in extension's Jobs tab (recipes) and Pipelines tab
|
|
1079
|
+
(pipelines): both show the same registry data with a "create
|
|
1080
|
+
local copy / instantiate" action. This page exists for
|
|
1081
|
+
discovery only. */}
|
|
1082
|
+
{(typeFilter === 'recipes' || typeFilter === 'pipelines') && (
|
|
1083
|
+
<WorkflowMarketplaceBrowser kind={typeFilter === 'recipes' ? 'recipe' : 'pipeline'} searchQuery={searchQuery} />
|
|
1084
|
+
)}
|
|
1085
|
+
</div>
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ─── Workflow Marketplace browser (read-only) ────────────────
|
|
1090
|
+
// Pulls /api/workflows/marketplace and renders recipes or pipelines.
|
|
1091
|
+
// Each row links to where you'd actually use it; no Install button here
|
|
1092
|
+
// because the use flow (Jobs/Pipelines tab) handles install implicitly.
|
|
1093
|
+
|
|
1094
|
+
interface MarketRow {
|
|
1095
|
+
kind: 'recipe' | 'pipeline';
|
|
1096
|
+
name: string;
|
|
1097
|
+
display_name: string;
|
|
1098
|
+
description?: string;
|
|
1099
|
+
version: string;
|
|
1100
|
+
author?: string;
|
|
1101
|
+
tags?: string[];
|
|
1102
|
+
source: 'registry' | 'local';
|
|
1103
|
+
installed: boolean;
|
|
1104
|
+
installed_version?: string;
|
|
1105
|
+
has_update?: boolean;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function WorkflowMarketplaceBrowser({ kind, searchQuery }: { kind: 'recipe' | 'pipeline'; searchQuery: string }) {
|
|
1109
|
+
const [rows, setRows] = useState<MarketRow[] | null>(null);
|
|
1110
|
+
const [busy, setBusy] = useState(false);
|
|
1111
|
+
const [err, setErr] = useState<string>('');
|
|
1112
|
+
|
|
1113
|
+
const useInLocation = kind === 'recipe' ? 'Jobs tab' : 'Pipelines tab';
|
|
1114
|
+
|
|
1115
|
+
const load = async () => {
|
|
1116
|
+
try {
|
|
1117
|
+
const res = await fetch('/api/workflows/marketplace');
|
|
1118
|
+
const data = await res.json();
|
|
1119
|
+
setRows((kind === 'recipe' ? data.recipes : data.pipelines) || []);
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
1122
|
+
setRows([]);
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const sync = async () => {
|
|
1127
|
+
setBusy(true);
|
|
1128
|
+
setErr('');
|
|
1129
|
+
try {
|
|
1130
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
1131
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1132
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
1133
|
+
});
|
|
1134
|
+
const data = await res.json();
|
|
1135
|
+
if (!data.ok) setErr(data.error || 'sync failed');
|
|
1136
|
+
await load();
|
|
1137
|
+
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
|
1138
|
+
finally { setBusy(false); }
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
useEffect(() => { void load(); }, [kind]);
|
|
1142
|
+
|
|
1143
|
+
const q = searchQuery.toLowerCase();
|
|
1144
|
+
const filtered = (rows || []).filter(r => !q
|
|
1145
|
+
|| r.name.toLowerCase().includes(q)
|
|
1146
|
+
|| r.display_name.toLowerCase().includes(q)
|
|
1147
|
+
|| (r.description || '').toLowerCase().includes(q)
|
|
1148
|
+
|| (r.tags || []).some(t => t.toLowerCase().includes(q)));
|
|
1149
|
+
|
|
1150
|
+
return (
|
|
1151
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
1152
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
|
|
1153
|
+
<span className="text-[10px] text-[var(--text-secondary)] flex-1">
|
|
1154
|
+
{kind === 'recipe' ? 'Job recipes' : 'Pipeline templates'} from <code className="font-mono">aiwatching/forge-workflow</code>
|
|
1155
|
+
{' · '}use them from the {useInLocation}
|
|
1156
|
+
</span>
|
|
1157
|
+
<button
|
|
1158
|
+
onClick={() => void sync()}
|
|
1159
|
+
disabled={busy}
|
|
1160
|
+
className="text-[9px] px-2 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
1161
|
+
>{busy ? 'Syncing…' : 'Sync'}</button>
|
|
1162
|
+
</div>
|
|
1163
|
+
{err && (
|
|
1164
|
+
<div className="px-4 py-2 text-[10px] text-[var(--red)] border-b border-[var(--border)]">{err}</div>
|
|
1165
|
+
)}
|
|
1166
|
+
<div className="p-3 space-y-2">
|
|
1167
|
+
{rows == null ? (
|
|
1168
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-8">Loading…</div>
|
|
1169
|
+
) : filtered.length === 0 ? (
|
|
1170
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-8">
|
|
1171
|
+
{rows.length === 0 ? <>Nothing in the registry yet — click <b>Sync</b>.</> : 'No matches for your search.'}
|
|
1172
|
+
</div>
|
|
1173
|
+
) : (
|
|
1174
|
+
filtered.map((r) => (
|
|
1175
|
+
<div key={r.name} className="border border-[var(--border)] rounded p-3 bg-[var(--bg-tertiary)]/30">
|
|
1176
|
+
<div className="flex items-baseline gap-2">
|
|
1177
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] flex-1">{r.display_name}</span>
|
|
1178
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono">v{r.version}</span>
|
|
1179
|
+
{/* Read-only catalog — no install/uninstall here; usage
|
|
1180
|
+
happens in Jobs / Pipelines tab and that's where the
|
|
1181
|
+
install state matters. */}
|
|
1182
|
+
{r.source === 'local' && <span className="text-[8px] text-[var(--text-secondary)]" title="Only on this machine — not in the marketplace registry">· local-only</span>}
|
|
1183
|
+
</div>
|
|
1184
|
+
{r.description && (
|
|
1185
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5 leading-relaxed whitespace-pre-wrap">{r.description}</p>
|
|
1186
|
+
)}
|
|
1187
|
+
<div className="mt-2 flex items-center gap-2">
|
|
1188
|
+
<code className="text-[9px] text-[var(--text-secondary)] font-mono">{r.name}</code>
|
|
1189
|
+
{r.author && <span className="text-[9px] text-[var(--text-secondary)]">· {r.author}</span>}
|
|
1190
|
+
{r.tags && r.tags.length > 0 && (
|
|
1191
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{r.tags.map(t => `#${t}`).join(' ')}</span>
|
|
1192
|
+
)}
|
|
1193
|
+
<span className="flex-1" />
|
|
1194
|
+
<span className="text-[9px] text-[var(--text-secondary)] italic">Use in {useInLocation} →</span>
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
))
|
|
1198
|
+
)}
|
|
1199
|
+
</div>
|
|
1044
1200
|
</div>
|
|
1045
1201
|
);
|
|
1046
1202
|
}
|
|
@@ -726,7 +726,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
726
726
|
const [pluginDefs, setPluginDefs] = useState<{ id: string; name: string; icon: string }[]>([]);
|
|
727
727
|
|
|
728
728
|
useEffect(() => {
|
|
729
|
-
|
|
729
|
+
// WorkspaceView shows BOTH CLI agents and API profiles (workspace
|
|
730
|
+
// agents can run in either backend), so opt into ?include=all.
|
|
731
|
+
fetch('/api/agents?include=all').then(r => r.json()).then(data => {
|
|
730
732
|
const list = (data.agents || data || []).map((a: any) => ({
|
|
731
733
|
id: a.id, name: a.name || a.id,
|
|
732
734
|
isProfile: a.isProfile || a.base,
|
package/install.sh
CHANGED
|
@@ -47,6 +47,34 @@ if ! command -v claude >/dev/null 2>&1 \
|
|
|
47
47
|
echo ""
|
|
48
48
|
fi
|
|
49
49
|
|
|
50
|
+
# Optional helpers used by built-in workflows. Warn-only — Forge installs
|
|
51
|
+
# fine without them, but specific pipelines will fail at the shell step
|
|
52
|
+
# if they're missing (e.g. mr-review-fix calls glab + jq).
|
|
53
|
+
missing_opts=()
|
|
54
|
+
command -v jq >/dev/null 2>&1 || missing_opts+=("jq")
|
|
55
|
+
command -v glab >/dev/null 2>&1 || missing_opts+=("glab")
|
|
56
|
+
if [ ${#missing_opts[@]} -gt 0 ]; then
|
|
57
|
+
echo "[forge] ⚠️ Optional CLIs missing: ${missing_opts[*]}"
|
|
58
|
+
echo "[forge] jq — needed by shell pipelines that parse JSON"
|
|
59
|
+
echo "[forge] glab — needed by the mr-review-fix workflow (GitLab API)"
|
|
60
|
+
case "$(uname -s)" in
|
|
61
|
+
Darwin)
|
|
62
|
+
echo "[forge] Install: brew install ${missing_opts[*]}"
|
|
63
|
+
if command -v brew >/dev/null 2>&1; then
|
|
64
|
+
read -r -p "[forge] Install now? [y/N] " reply
|
|
65
|
+
case "$reply" in [yY]*) brew install "${missing_opts[@]}" ;; esac
|
|
66
|
+
fi
|
|
67
|
+
;;
|
|
68
|
+
Linux)
|
|
69
|
+
echo "[forge] Install via your distro's package manager."
|
|
70
|
+
case " ${missing_opts[*]} " in
|
|
71
|
+
*" glab "*) echo "[forge] glab — see https://gitlab.com/gitlab-org/cli#installation" ;;
|
|
72
|
+
esac
|
|
73
|
+
;;
|
|
74
|
+
esac
|
|
75
|
+
echo ""
|
|
76
|
+
fi
|
|
77
|
+
|
|
50
78
|
if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
|
|
51
79
|
echo "[forge] Installing from local source..."
|
|
52
80
|
npm uninstall -g @aion0/forge 2>/dev/null || true
|
package/lib/agents/index.ts
CHANGED
|
@@ -78,7 +78,12 @@ export function listAgents(): AgentConfig[] {
|
|
|
78
78
|
for (const [id, cfg] of Object.entries(settings.agents)) {
|
|
79
79
|
if (['claude', 'codex', 'aider'].includes(id)) continue;
|
|
80
80
|
|
|
81
|
-
// API profile — no CLI detection needed
|
|
81
|
+
// API profile — no CLI detection needed. Tagged with
|
|
82
|
+
// backendType='api' so CLI-only dropdowns (Terminal launcher,
|
|
83
|
+
// NewTaskModal etc.) can filter them out via
|
|
84
|
+
// `agents.filter(a => a.backendType !== 'api')`. WorkspaceView's
|
|
85
|
+
// API mode keeps them visible. Chat backend doesn't go through
|
|
86
|
+
// this path at all — it reads settings.agents directly.
|
|
82
87
|
if (cfg.type === 'api') {
|
|
83
88
|
agents.push({
|
|
84
89
|
id,
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -78,11 +78,45 @@ function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
|
|
|
78
78
|
return 'openai';
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Provider → default public API URL. Saves the user from having to know
|
|
83
|
+
* the right host for first-party providers; only LiteLLM (which is by
|
|
84
|
+
* definition a self-hosted proxy) requires an explicit URL.
|
|
85
|
+
*
|
|
86
|
+
* Resolution priority: profile.baseUrl > env.*_BASE_URL > provider default.
|
|
87
|
+
*/
|
|
88
|
+
export function defaultBaseUrl(provider: string | undefined): string {
|
|
89
|
+
switch ((provider || '').toLowerCase()) {
|
|
90
|
+
case 'anthropic':
|
|
91
|
+
case 'claude':
|
|
92
|
+
return 'https://api.anthropic.com';
|
|
93
|
+
case 'openai':
|
|
94
|
+
return 'https://api.openai.com/v1';
|
|
95
|
+
case 'grok':
|
|
96
|
+
case 'xai':
|
|
97
|
+
return 'https://api.x.ai/v1';
|
|
98
|
+
case 'google':
|
|
99
|
+
case 'gemini':
|
|
100
|
+
// Google's Gemini exposes an OpenAI-compatible shim at this path.
|
|
101
|
+
return 'https://generativelanguage.googleapis.com/v1beta/openai';
|
|
102
|
+
case 'deepseek':
|
|
103
|
+
return 'https://api.deepseek.com';
|
|
104
|
+
default:
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pickBaseUrl(
|
|
110
|
+
profile: { baseUrl?: string; env?: Record<string, string>; provider?: string },
|
|
111
|
+
adapter: 'anthropic' | 'openai',
|
|
112
|
+
): string {
|
|
82
113
|
if (profile.baseUrl) return profile.baseUrl;
|
|
83
114
|
const env = profile.env || {};
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
const envOverride = adapter === 'anthropic'
|
|
116
|
+
? env.ANTHROPIC_BASE_URL
|
|
117
|
+
: (env.OPENAI_BASE_URL || env.OPENAI_API_BASE);
|
|
118
|
+
if (envOverride) return envOverride;
|
|
119
|
+
return defaultBaseUrl(profile.provider);
|
|
86
120
|
}
|
|
87
121
|
|
|
88
122
|
function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
|
|
@@ -16,10 +16,13 @@ function historyToApi(history: Message[]): Anthropic.MessageParam[] {
|
|
|
16
16
|
if (b.type === 'text') {
|
|
17
17
|
if (b.text.length > 0) content.push({ type: 'text', text: b.text });
|
|
18
18
|
} else if (b.type === 'tool_use') {
|
|
19
|
+
// Same encoding as tool definitions — names with `.` must
|
|
20
|
+
// round-trip through Anthropic as `__` to satisfy its tool name
|
|
21
|
+
// regex when this turn's history is re-sent next turn.
|
|
19
22
|
content.push({
|
|
20
23
|
type: 'tool_use',
|
|
21
24
|
id: b.id,
|
|
22
|
-
name: b.name,
|
|
25
|
+
name: b.name.replace(/\./g, '__'),
|
|
23
26
|
input: (b.input ?? {}) as Record<string, unknown>,
|
|
24
27
|
});
|
|
25
28
|
} else if (b.type === 'tool_result') {
|
|
@@ -64,6 +67,20 @@ export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic
|
|
|
64
67
|
return new Anthropic(opts);
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Anthropic restricts tool names to `^[a-zA-Z0-9_-]{1,128}$`. Forge's
|
|
72
|
+
* connector tools use a `connector.tool` namespace (e.g.
|
|
73
|
+
* `gitlab.list_my_mrs`), so we encode the dot as `__` on the way out and
|
|
74
|
+
* decode it back when Anthropic returns a tool_use. Internal dispatcher
|
|
75
|
+
* still sees the canonical dotted form.
|
|
76
|
+
*/
|
|
77
|
+
function encodeToolName(name: string): string {
|
|
78
|
+
return name.replace(/\./g, '__');
|
|
79
|
+
}
|
|
80
|
+
function decodeToolName(name: string): string {
|
|
81
|
+
return name.replace(/__/g, '.');
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
export const anthropicAdapter: LlmAdapter = {
|
|
68
85
|
async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
69
86
|
const client = makeAnthropicClient(req.apiKey, req.baseUrl);
|
|
@@ -73,7 +90,7 @@ export const anthropicAdapter: LlmAdapter = {
|
|
|
73
90
|
max_tokens: req.maxTokens,
|
|
74
91
|
system: req.system,
|
|
75
92
|
tools: req.tools.map((t) => ({
|
|
76
|
-
name: t.name,
|
|
93
|
+
name: encodeToolName(t.name),
|
|
77
94
|
description: t.description,
|
|
78
95
|
input_schema: t.input_schema as Anthropic.Tool.InputSchema,
|
|
79
96
|
})),
|
|
@@ -89,8 +106,9 @@ export const anthropicAdapter: LlmAdapter = {
|
|
|
89
106
|
if (block.type === 'text') {
|
|
90
107
|
content.push({ type: 'text', text: block.text });
|
|
91
108
|
} else if (block.type === 'tool_use') {
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
const decodedName = decodeToolName(block.name);
|
|
110
|
+
cb.onToolUse({ id: block.id, name: decodedName, input: block.input });
|
|
111
|
+
content.push({ type: 'tool_use', id: block.id, name: decodedName, input: block.input });
|
|
94
112
|
}
|
|
95
113
|
}
|
|
96
114
|
|
|
@@ -22,6 +22,13 @@ export interface HttpProtocolArgs {
|
|
|
22
22
|
tool: ConnectorTool;
|
|
23
23
|
settings: Record<string, any>;
|
|
24
24
|
args: Record<string, any>;
|
|
25
|
+
/**
|
|
26
|
+
* When true, return the full response body without the 8KB cap. Used by
|
|
27
|
+
* the Jobs scheduler — it parses JSON, not feeds the response into an
|
|
28
|
+
* LLM context, so truncation breaks `JSON.parse` for any response
|
|
29
|
+
* larger than 8KB (Todos / search responses routinely exceed this).
|
|
30
|
+
*/
|
|
31
|
+
noTruncation?: boolean;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface HttpProtocolResult {
|
|
@@ -45,8 +52,39 @@ function expandObjectLeaves(obj: any, settings: Record<string, any>, args: Recor
|
|
|
45
52
|
return obj;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Expand `{args.X}` placeholders in a URL path with the value URL-
|
|
57
|
+
* encoded. `{settings.X}` is NOT encoded — `{settings.base_url}` is the
|
|
58
|
+
* scheme + host (with its own `://` and `/`), which must stay literal.
|
|
59
|
+
*
|
|
60
|
+
* Why: GitLab and many REST APIs accept either a numeric id or a
|
|
61
|
+
* URL-encoded namespace path (`fortinac%2FFortiNAC`) as the project
|
|
62
|
+
* identifier in the path. Without encoding, `args.project_id =
|
|
63
|
+
* "fortinac/FortiNAC"` interpolates as a raw `/` and turns
|
|
64
|
+
* `/projects/{args.project_id}/...` into `/projects/fortinac/FortiNAC/...`
|
|
65
|
+
* (extra path segment), which the API can't parse. Numeric ids encode
|
|
66
|
+
* to themselves — no regression.
|
|
67
|
+
*/
|
|
68
|
+
function expandUrlPath(template: string, settings: Record<string, any>, args: Record<string, any>): string {
|
|
69
|
+
// First handle settings.* with raw substitution (keeps base_url intact).
|
|
70
|
+
let out = expandAllTokens(template, settings, {});
|
|
71
|
+
// Then handle args.* with URL-encoding.
|
|
72
|
+
out = out.replace(/\{args\.([^{}]+)\}/g, (full, rawKey) => {
|
|
73
|
+
const path = String(rawKey).trim().split('.');
|
|
74
|
+
let v: any = args;
|
|
75
|
+
for (const p of path) {
|
|
76
|
+
if (v == null || typeof v !== 'object') { v = undefined; break; }
|
|
77
|
+
v = v[p];
|
|
78
|
+
}
|
|
79
|
+
if (v == null) return full;
|
|
80
|
+
const s = typeof v === 'string' ? v : (typeof v === 'number' || typeof v === 'boolean' ? String(v) : JSON.stringify(v));
|
|
81
|
+
return encodeURIComponent(s);
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): string {
|
|
49
|
-
const base =
|
|
87
|
+
const base = expandUrlPath(spec.url, settings, args);
|
|
50
88
|
if (!spec.query) return base;
|
|
51
89
|
const url = new URL(base);
|
|
52
90
|
for (const [k, raw] of Object.entries(spec.query)) {
|
|
@@ -86,7 +124,7 @@ function truncate(s: string): { text: string; truncated: boolean; totalBytes: nu
|
|
|
86
124
|
return { text: slice, truncated: true, totalBytes: buf.byteLength };
|
|
87
125
|
}
|
|
88
126
|
|
|
89
|
-
export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promise<HttpProtocolResult> {
|
|
127
|
+
export async function runHttp({ tool, settings, args, noTruncation }: HttpProtocolArgs): Promise<HttpProtocolResult> {
|
|
90
128
|
const spec = tool.request;
|
|
91
129
|
if (!spec || !spec.url) {
|
|
92
130
|
return { content: 'http tool missing `request.url`', is_error: true };
|
|
@@ -125,6 +163,12 @@ export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promi
|
|
|
125
163
|
clearTimeout(timer);
|
|
126
164
|
|
|
127
165
|
const text = await res.text().catch(() => '');
|
|
166
|
+
if (noTruncation) {
|
|
167
|
+
// Jobs scheduler / preview path — return the body verbatim so
|
|
168
|
+
// JSON.parse works on responses larger than MAX_BODY_BYTES. No
|
|
169
|
+
// preamble either; callers parse content directly.
|
|
170
|
+
return { content: text, is_error: !res.ok };
|
|
171
|
+
}
|
|
128
172
|
const { text: shown, truncated, totalBytes } = truncate(text);
|
|
129
173
|
const preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n` +
|
|
130
174
|
(truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n');
|
|
@@ -130,11 +130,29 @@ function buildConnectorPayload(
|
|
|
130
130
|
|
|
131
131
|
// ─── Public entry ─────────────────────────────────────────
|
|
132
132
|
|
|
133
|
+
export interface DispatchOptions {
|
|
134
|
+
/** Per-turn extra builtins (e.g. memory_* when Temper is configured). */
|
|
135
|
+
extraBuiltins?: Record<string, BuiltinHandler>;
|
|
136
|
+
/**
|
|
137
|
+
* Skip the HTTP-protocol 8KB body cap. Set by callers that parse the
|
|
138
|
+
* response programmatically (Jobs scheduler, /api/jobs/preview) and
|
|
139
|
+
* therefore don't need an LLM-friendly truncation.
|
|
140
|
+
*/
|
|
141
|
+
noTruncation?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
export async function dispatchTool(
|
|
134
145
|
call: ToolCall,
|
|
135
|
-
|
|
136
|
-
extraBuiltins?: Record<string, BuiltinHandler>,
|
|
146
|
+
optsOrExtraBuiltins?: DispatchOptions | Record<string, BuiltinHandler>,
|
|
137
147
|
): Promise<ToolResult> {
|
|
148
|
+
// Back-compat: accept either DispatchOptions or a bare extraBuiltins map
|
|
149
|
+
// so older callers (chat agent-loop etc.) keep working without edits.
|
|
150
|
+
const opts: DispatchOptions = optsOrExtraBuiltins && 'extraBuiltins' in (optsOrExtraBuiltins as any)
|
|
151
|
+
? (optsOrExtraBuiltins as DispatchOptions)
|
|
152
|
+
: optsOrExtraBuiltins && 'noTruncation' in (optsOrExtraBuiltins as any)
|
|
153
|
+
? (optsOrExtraBuiltins as DispatchOptions)
|
|
154
|
+
: { extraBuiltins: optsOrExtraBuiltins as Record<string, BuiltinHandler> | undefined };
|
|
155
|
+
const extraBuiltins = opts.extraBuiltins;
|
|
138
156
|
// Per-turn builtins first (memory tools), then global builtins.
|
|
139
157
|
const dynBuiltin = extraBuiltins?.[call.name];
|
|
140
158
|
if (dynBuiltin) {
|
|
@@ -164,7 +182,7 @@ export async function dispatchTool(
|
|
|
164
182
|
try {
|
|
165
183
|
switch (protocol) {
|
|
166
184
|
case 'http':
|
|
167
|
-
return await runHttp({ tool: located.tool, settings: located.settings, args: argInput });
|
|
185
|
+
return await runHttp({ tool: located.tool, settings: located.settings, args: argInput, noTruncation: opts.noTruncation });
|
|
168
186
|
case 'shell':
|
|
169
187
|
return await runShell({ tool: located.tool, settings: located.settings, args: argInput });
|
|
170
188
|
case 'browser': {
|