@aion0/forge 0.8.5 → 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.
@@ -860,9 +860,10 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
860
860
  <select value={cfg.provider || 'anthropic'} onChange={e => onUpdate({ ...cfg, provider: e.target.value })} className={inputClass}>
861
861
  <option value="anthropic">Anthropic</option>
862
862
  <option value="openai">OpenAI</option>
863
+ <option value="grok">Grok / xAI</option>
864
+ <option value="google">Google / Gemini</option>
865
+ <option value="deepseek">DeepSeek</option>
863
866
  <option value="litellm">LiteLLM (OpenAI-compatible proxy)</option>
864
- <option value="grok">Grok</option>
865
- <option value="google">Google</option>
866
867
  </select>
867
868
  </div>
868
869
  <div className="flex-1">
@@ -871,11 +872,28 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
871
872
  </div>
872
873
  </div>
873
874
  <div>
874
- <label className="text-[8px] text-[var(--text-secondary)]">Base URL (optional · LiteLLM / Azure / self-hosted proxy)</label>
875
+ <label className="text-[8px] text-[var(--text-secondary)]">
876
+ Base URL {(() => {
877
+ const p = (cfg.provider || '').toLowerCase();
878
+ if (p === 'litellm') return '— required (LiteLLM / proxy)';
879
+ if (p === '' || p === 'other') return '— optional';
880
+ return '— optional, leave empty to use the provider default below';
881
+ })()}
882
+ </label>
875
883
  <input
876
884
  value={cfg.baseUrl || ''}
877
885
  onChange={e => onUpdate({ ...cfg, baseUrl: e.target.value })}
878
- placeholder={cfg.provider === 'anthropic' ? 'https://api.anthropic.com' : 'http://127.0.0.1:4000/v1'}
886
+ placeholder={(() => {
887
+ switch ((cfg.provider || '').toLowerCase()) {
888
+ case 'anthropic': return 'https://api.anthropic.com (default)';
889
+ case 'openai': return 'https://api.openai.com/v1 (default)';
890
+ case 'grok': case 'xai': return 'https://api.x.ai/v1 (default)';
891
+ case 'google': case 'gemini': return 'https://generativelanguage.googleapis.com/v1beta/openai (default)';
892
+ case 'deepseek': return 'https://api.deepseek.com (default)';
893
+ case 'litellm': return 'http://127.0.0.1:4000/v1';
894
+ default: return 'https://...';
895
+ }
896
+ })()}
879
897
  className={inputClass + ' font-mono'}
880
898
  />
881
899
  </div>
@@ -1085,9 +1103,10 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1085
1103
  <select value={provider} onChange={e => setProvider(e.target.value)} className={inputClass}>
1086
1104
  <option value="anthropic">Anthropic</option>
1087
1105
  <option value="openai">OpenAI</option>
1106
+ <option value="grok">Grok / xAI</option>
1107
+ <option value="google">Google / Gemini</option>
1108
+ <option value="deepseek">DeepSeek</option>
1088
1109
  <option value="litellm">LiteLLM (OpenAI-compatible proxy)</option>
1089
- <option value="grok">Grok</option>
1090
- <option value="google">Google</option>
1091
1110
  </select>
1092
1111
  </div>
1093
1112
  <div className="flex-1">
@@ -1100,11 +1119,23 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1100
1119
  <input type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="sk-..." className={inputClass} />
1101
1120
  </div>
1102
1121
  <div>
1103
- <label className="text-[8px] text-[var(--text-secondary)]">Base URL (optional · LiteLLM / Azure / self-hosted)</label>
1122
+ <label className="text-[8px] text-[var(--text-secondary)]">
1123
+ Base URL {provider === 'litellm' ? '(required)' : '(optional — leave empty for provider default)'}
1124
+ </label>
1104
1125
  <input
1105
1126
  value={baseUrl}
1106
1127
  onChange={e => setBaseUrl(e.target.value)}
1107
- placeholder={provider === 'anthropic' ? 'https://api.anthropic.com' : 'http://127.0.0.1:4000/v1'}
1128
+ placeholder={(() => {
1129
+ switch (provider) {
1130
+ case 'anthropic': return 'https://api.anthropic.com (default)';
1131
+ case 'openai': return 'https://api.openai.com/v1 (default)';
1132
+ case 'grok': return 'https://api.x.ai/v1 (default)';
1133
+ case 'google': return 'https://generativelanguage.googleapis.com/v1beta/openai (default)';
1134
+ case 'deepseek': return 'https://api.deepseek.com (default)';
1135
+ case 'litellm': return 'http://127.0.0.1:4000/v1';
1136
+ default: return 'https://...';
1137
+ }
1138
+ })()}
1108
1139
  className={inputClass + ' font-mono'}
1109
1140
  />
1110
1141
  </div>
@@ -1175,9 +1206,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1175
1206
  });
1176
1207
  }
1177
1208
 
1178
- // Add configured but not detected agents
1209
+ // Add configured but not detected agents. Skip rows that are
1210
+ // profiles (CLI profile with `base`, or API profile with
1211
+ // `type: 'api'`) — those have their own Profiles section below
1212
+ // and don't belong in the Agents list.
1179
1213
  for (const [id, cfg] of Object.entries(configured) as [string, any][]) {
1180
1214
  if (merged.find(a => a.id === id)) continue;
1215
+ if (cfg.type === 'api' || cfg.base) continue;
1181
1216
  merged.push({
1182
1217
  id,
1183
1218
  name: cfg.name ?? id,
@@ -1751,7 +1786,7 @@ function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings
1751
1786
  <div className="mt-4 pt-3 border-t border-[var(--border)]">
1752
1787
  <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase mb-2 block">Chat backend</label>
1753
1788
  <div className="flex items-center gap-2">
1754
- <span className="text-[9px] text-[var(--text-secondary)]">Default chat agent:</span>
1789
+ <span className="text-[9px] text-[var(--text-secondary)]">Default chat profile:</span>
1755
1790
  <select
1756
1791
  value={settings.chatAgent || ''}
1757
1792
  onChange={e => setSettings({ ...settings, chatAgent: e.target.value })}
@@ -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 }[]>([]);
@@ -414,21 +414,31 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
414
414
  <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
415
415
  <div className="flex items-center gap-2">
416
416
  <span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
417
- <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
418
- {([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules'], ['plugins', 'Plugins'], ['connectors', 'Connectors'], ['crafts', 'Crafts']] as const).map(([value, label]) => (
419
- <button
420
- key={value}
421
- onClick={() => setTypeFilter(value)}
422
- className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
423
- typeFilter === value
424
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
425
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
426
- }`}
427
- >
428
- {label}
429
- </button>
430
- ))}
431
- </div>
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>
432
442
  </div>
433
443
  <span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
434
444
  <input
@@ -470,7 +480,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
470
480
  />
471
481
  </div>}
472
482
 
473
- {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 ? (
474
484
  <div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
475
485
  <p className="text-xs">No skills yet</p>
476
486
  <button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
@@ -1063,6 +1073,130 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
1063
1073
  <CraftsMarketplacePanelLazy searchQuery={searchQuery} />
1064
1074
  </Suspense>
1065
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>
1066
1200
  </div>
1067
1201
  );
1068
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
- fetch('/api/agents').then(r => r.json()).then(data => {
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
@@ -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,
@@ -78,11 +78,45 @@ function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
78
78
  return 'openai';
79
79
  }
80
80
 
81
- function pickBaseUrl(profile: { baseUrl?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
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
- if (adapter === 'anthropic') return env.ANTHROPIC_BASE_URL || '';
85
- return env.OPENAI_BASE_URL || env.OPENAI_API_BASE || '';
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
- cb.onToolUse({ id: block.id, name: block.name, input: block.input });
93
- content.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
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 = expandAllTokens(spec.url, settings, args);
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
- /** Per-turn extra builtins (e.g. memory_* when Temper is configured). */
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': {