@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.
@@ -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
- // Filter out items already in registry
178
- const registryNames = new Set((data.skills || []).map((s: any) => s.name));
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
- <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
398
- {([['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]) => (
399
- <button
400
- key={value}
401
- onClick={() => setTypeFilter(value)}
402
- className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
403
- typeFilter === value
404
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
405
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
406
- }`}
407
- >
408
- {label}
409
- </button>
410
- ))}
411
- </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>
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 a local skill — SKILL.md or a .zip bundle. Falls outside the forge-skills registry."
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
- {(typeFilter === 'all' || typeFilter === 'local') && filteredLocal.length > 0 && (
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
- 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': {