@aion0/forge 0.9.13 → 0.9.14

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 CHANGED
@@ -1,19 +1,15 @@
1
- # Forge v0.9.13
1
+ # Forge v0.9.14
2
2
 
3
3
  Released: 2026-05-27
4
4
 
5
- ## Changes since v0.9.12
5
+ ## Changes since v0.9.13
6
6
 
7
7
  ### Other
8
- - refactor(schedules): drop skill + connector_tool body kinds (web)
9
- - fix(schedules): prompt mode end-to-end — MCP config + zombie reconciler
10
- - fix(schedules-modal): normalize 'scratch' '' on edit-mode load
11
- - feat(projects): synthetic 'scratch' project as default workspace
12
- - feat(schedules): natural-language quick create extractor + Confirm Card
13
- - feat(schedules-ui): prompt mode in ScheduleCreateModal + SchedulesView
14
- - feat(schedules): body_kind='prompt' dispatch + task-backed run settle
15
- - feat(prompts): store + CRUD API for V3 schedule body_kind='prompt'
16
- - feat(mcp): list_connectors + call_connector tools for ai-orchestration
8
+ - fix(pipeline): apply workflow.input defaults for missing fields
9
+ - feat(chat): trigger_pipeline schema validation + self-evolving rules
10
+ - feat(chat): pipeline input schemas + Forge context tools
11
+ - refactor(chat): LLM layer on Vercel AI SDK (provider-agnostic streaming)
12
+ - feat(chat): trigger_pipeline + dispatch_task builtin tools
17
13
 
18
14
 
19
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.12...v0.9.13
15
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.13...v0.9.14
@@ -164,6 +164,18 @@ function resolveProvider(sessionProvider: string | null, sessionModel: string |
164
164
 
165
165
  function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTIN_TOOL_DEFS, sessionSystemPrompt: string | null): string {
166
166
  const now = new Date().toISOString();
167
+
168
+ // Inject a brief Forge context block (project names only) so the LLM can
169
+ // validate names the user mentions ("FortiNAC" → real project? → yes) and
170
+ // pass them to trigger_pipeline / dispatch_task without guessing. Full
171
+ // details (paths, agents, skills) are behind list_forge_context — only
172
+ // names are cheap enough to ship every turn.
173
+ let projectNames: string[] = [];
174
+ try {
175
+ const { scanProjects } = require('../projects') as typeof import('../projects');
176
+ projectNames = scanProjects().map((p) => p.name);
177
+ } catch { /* projects roots not configured / read failed — omit */ }
178
+
167
179
  const lines: string[] = [
168
180
  "You are Forge, the user's personal AI assistant.",
169
181
  '',
@@ -174,6 +186,14 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
174
186
  ' Don\'t explain how to do something manually before trying the tool. The tools below run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
175
187
  '- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
176
188
  '- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
189
+ '- For trigger_pipeline / dispatch_task: when the user names a "project" (e.g. "FortiNAC"), pass it as input.project verbatim. The names in the "Forge projects" list below ARE the valid values. Call list_forge_context only if you need paths / agents / skills.',
190
+ '',
191
+ 'trigger_pipeline specifics — these are easy to get wrong, READ CAREFULLY:',
192
+ '- FIRST call this session: call trigger_pipeline() with NO arguments. The response lists every workflow + which input fields are required (*) vs have defaults. Field names are EXACT, snake_case (e.g. bug_id), declared by the workflow yaml. They are NOT the same as bash variable names inside pipeline scripts (BUG_ID / BASE / PROJECT_PATH are wrong). DO NOT pass uppercase / made-up names.',
193
+ '- For optional fields with defaults (mr_body_template / user_prompt / teams_message_template / etc.), OMIT them — let the default apply. NEVER pass empty strings or invented placeholder values.',
194
+ '- If the response says "Unknown input fields", "Missing required", or "0 iterations" — the pipeline did NOT do what the user asked. Fix the input and retry. Optionally save a pinned memory rule via memory_remember_block({pinned: true, ...}) so the lesson sticks for future sessions.',
195
+ '- DO NOT trust earlier assistant messages in this conversation that claim a pipeline "already ran" — those may be wrong. If the user re-asks, fire fresh; verify only by re-checking the actual target system (e.g. mantis.get_bug for status).',
196
+ '',
177
197
  '- Reply without tools ONLY when no system + no time question is involved.',
178
198
  '',
179
199
  'Other:',
@@ -181,6 +201,10 @@ function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTI
181
201
  'Keep replies short and direct.',
182
202
  ];
183
203
 
204
+ if (projectNames.length > 0) {
205
+ lines.push('', `Forge projects (valid input.project values): ${projectNames.join(', ')}`);
206
+ }
207
+
184
208
  if (connectorTools.length > 0) {
185
209
  lines.push('', 'Connector tools available:');
186
210
  for (const t of connectorTools) {
@@ -1,64 +1,36 @@
1
1
  /**
2
- * Anthropic adapter — ported from the extension. Server-side variant
3
- * (no dangerouslyAllowBrowser). OAuth tokens (sk-ant-oat-*) get the
4
- * special beta header + authToken handling.
2
+ * Anthropic adapter — uses Vercel AI SDK (@ai-sdk/anthropic + ai).
3
+ * Public contract (streamLlm interface) is unchanged from the hand-rolled
4
+ * version this replaced; agent-loop.ts callers see no difference.
5
+ *
6
+ * Forge-specific bits we keep:
7
+ * - OAuth token (sk-ant-oat-*) → Authorization: Bearer + oauth beta header
8
+ * - Connector tool names (dotted, e.g. "gitlab.list_my_mrs") encoded as
9
+ * "__" to satisfy Anthropic's name regex; decoded on tool_use events
5
10
  */
6
11
 
7
12
  import Anthropic from '@anthropic-ai/sdk';
8
- import type { Message } from '../types';
13
+ import { createAnthropic } from '@ai-sdk/anthropic';
14
+ import { jsonSchema, streamText, type ModelMessage } from 'ai';
15
+ import type { ContentBlock, Message } from '../types';
9
16
  import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, StopReason } from './types';
10
17
 
11
- function historyToApi(history: Message[]): Anthropic.MessageParam[] {
12
- const out: Anthropic.MessageParam[] = [];
13
- for (const m of history) {
14
- const content: Anthropic.ContentBlockParam[] = [];
15
- for (const b of m.blocks) {
16
- if (b.type === 'text') {
17
- if (b.text.length > 0) content.push({ type: 'text', text: b.text });
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.
22
- content.push({
23
- type: 'tool_use',
24
- id: b.id,
25
- name: b.name.replace(/\./g, '__'),
26
- input: (b.input ?? {}) as Record<string, unknown>,
27
- });
28
- } else if (b.type === 'tool_result') {
29
- content.push({
30
- type: 'tool_result',
31
- tool_use_id: b.tool_use_id,
32
- content: b.content,
33
- is_error: b.is_error,
34
- });
35
- }
36
- }
37
- if (content.length === 0) continue;
38
- out.push({ role: m.role, content });
39
- }
40
- return out;
41
- }
42
-
43
- function mapStop(r: Anthropic.Message['stop_reason']): StopReason {
44
- switch (r) {
45
- case 'end_turn': return 'end_turn';
46
- case 'tool_use': return 'tool_use';
47
- case 'max_tokens': return 'max_tokens';
48
- case 'refusal': return 'refusal';
49
- default: return 'other';
50
- }
51
- }
52
-
53
18
  function isOauthToken(key: string): boolean {
54
19
  return key.startsWith('sk-ant-oat');
55
20
  }
56
21
 
22
+ /**
23
+ * Raw Anthropic SDK client (one-shot calls, non-streaming). Used by
24
+ * /api/schedules/extract for structured JSON extraction. Kept here so OAuth
25
+ * + baseURL handling matches the streaming adapter; callers wanting tool
26
+ * calls / streaming should use streamLlm via the Vercel AI SDK adapter below.
27
+ */
57
28
  export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic {
29
+ const oauth = isOauthToken(apiKey);
58
30
  const opts: ConstructorParameters<typeof Anthropic>[0] = {
59
31
  baseURL: baseUrl || undefined,
60
32
  };
61
- if (isOauthToken(apiKey)) {
33
+ if (oauth) {
62
34
  opts.authToken = apiKey;
63
35
  opts.defaultHeaders = { 'anthropic-beta': 'oauth-2025-04-20' };
64
36
  } else {
@@ -69,49 +41,127 @@ export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic
69
41
 
70
42
  /**
71
43
  * 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.
44
+ * connector tools use a `connector.tool` namespace, so we encode the
45
+ * dot as `__` on the way out and decode it back on tool_use. Internal
46
+ * dispatcher still sees the canonical dotted form.
76
47
  */
77
- function encodeToolName(name: string): string {
78
- return name.replace(/\./g, '__');
48
+ function encodeToolName(name: string): string { return name.replace(/\./g, '__'); }
49
+ function decodeToolName(name: string): string { return name.replace(/__/g, '.'); }
50
+
51
+ function makeClient(apiKey: string, baseUrl: string) {
52
+ const oauth = isOauthToken(apiKey);
53
+ return createAnthropic({
54
+ apiKey: oauth ? '' : apiKey,
55
+ baseURL: baseUrl || undefined,
56
+ headers: oauth
57
+ ? {
58
+ authorization: `Bearer ${apiKey}`,
59
+ 'anthropic-beta': 'oauth-2025-04-20',
60
+ }
61
+ : undefined,
62
+ });
79
63
  }
80
- function decodeToolName(name: string): string {
81
- return name.replace(/__/g, '.');
64
+
65
+ /** Convert Forge Message[] → AI SDK ModelMessage[]. */
66
+ function historyToModelMessages(history: Message[]): ModelMessage[] {
67
+ const out: ModelMessage[] = [];
68
+ for (const m of history) {
69
+ if (m.role === 'user') {
70
+ // User message may carry plain text OR tool_result blocks.
71
+ const toolResults = m.blocks.filter((b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result');
72
+ if (toolResults.length > 0) {
73
+ out.push({
74
+ role: 'tool',
75
+ content: toolResults.map((r) => ({
76
+ type: 'tool-result' as const,
77
+ toolCallId: r.tool_use_id,
78
+ toolName: '', // tool name not tracked in our model; SDK ignores when matching by toolCallId
79
+ output: { type: 'text', value: r.content },
80
+ })),
81
+ });
82
+ continue;
83
+ }
84
+ const text = m.blocks
85
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
86
+ .map((b) => b.text).join('\n');
87
+ if (text.length > 0) out.push({ role: 'user', content: text });
88
+ } else {
89
+ // Assistant: text + tool_use blocks
90
+ const parts: any[] = [];
91
+ for (const b of m.blocks) {
92
+ if (b.type === 'text' && b.text.length > 0) {
93
+ parts.push({ type: 'text', text: b.text });
94
+ } else if (b.type === 'tool_use') {
95
+ parts.push({
96
+ type: 'tool-call',
97
+ toolCallId: b.id,
98
+ toolName: encodeToolName(b.name),
99
+ input: b.input ?? {},
100
+ });
101
+ }
102
+ }
103
+ if (parts.length > 0) out.push({ role: 'assistant', content: parts });
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ function mapStop(r: string | undefined): StopReason {
110
+ switch (r) {
111
+ case 'stop': return 'end_turn';
112
+ case 'tool-calls': return 'tool_use';
113
+ case 'length': return 'max_tokens';
114
+ case 'content-filter': return 'refusal';
115
+ case 'error': return 'error';
116
+ default: return 'other';
117
+ }
82
118
  }
83
119
 
84
120
  export const anthropicAdapter: LlmAdapter = {
85
121
  async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
86
- const client = makeAnthropicClient(req.apiKey, req.baseUrl);
122
+ const client = makeClient(req.apiKey, req.baseUrl);
87
123
 
88
- const stream = client.messages.stream({
89
- model: req.model,
90
- max_tokens: req.maxTokens,
91
- system: req.system,
92
- tools: req.tools.map((t) => ({
93
- name: encodeToolName(t.name),
124
+ // Build tool set as Record<encoded_name, ToolDef>. We DO NOT supply
125
+ // execute — chat owns dispatch (destructive confirm, browser bridge,
126
+ // memory tools etc all live in agent-loop). Setting stopWhen with
127
+ // stepCountIs(1) prevents the SDK from auto-rolling a second step.
128
+ const tools: Record<string, any> = {};
129
+ for (const t of req.tools) {
130
+ tools[encodeToolName(t.name)] = {
94
131
  description: t.description,
95
- input_schema: t.input_schema as Anthropic.Tool.InputSchema,
96
- })),
97
- messages: historyToApi(req.history),
98
- });
99
-
100
- stream.on('text', (delta: string) => cb.onTextDelta(delta));
132
+ inputSchema: jsonSchema(t.input_schema),
133
+ };
134
+ }
101
135
 
102
- const final = await stream.finalMessage();
136
+ const result = streamText({
137
+ model: client(req.model),
138
+ system: req.system,
139
+ messages: historyToModelMessages(req.history),
140
+ tools,
141
+ maxOutputTokens: req.maxTokens,
142
+ });
103
143
 
104
- const content: LlmTurnResult['content'] = [];
105
- for (const block of final.content) {
106
- if (block.type === 'text') {
107
- content.push({ type: 'text', text: block.text });
108
- } else if (block.type === 'tool_use') {
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 });
144
+ const content: ContentBlock[] = [];
145
+ let textBuf = '';
146
+ for await (const part of result.fullStream) {
147
+ if (part.type === 'text-delta') {
148
+ textBuf += part.text;
149
+ cb.onTextDelta(part.text);
150
+ } else if (part.type === 'tool-call') {
151
+ if (textBuf.length > 0) {
152
+ content.push({ type: 'text', text: textBuf });
153
+ textBuf = '';
154
+ }
155
+ const decoded = decodeToolName(part.toolName);
156
+ cb.onToolUse({ id: part.toolCallId, name: decoded, input: part.input });
157
+ content.push({ type: 'tool_use', id: part.toolCallId, name: decoded, input: part.input });
158
+ } else if (part.type === 'error') {
159
+ throw new Error(`Anthropic stream error: ${String((part as any).error)}`);
112
160
  }
113
161
  }
162
+ if (textBuf.length > 0) content.push({ type: 'text', text: textBuf });
114
163
 
115
- return { stopReason: mapStop(final.stop_reason), content };
164
+ const finishReason = await result.finishReason;
165
+ return { stopReason: mapStop(finishReason), content };
116
166
  },
117
167
  };
@@ -1,215 +1,110 @@
1
+ /**
2
+ * OpenAI-compatible adapter — uses Vercel AI SDK (@ai-sdk/openai + ai).
3
+ * Public contract unchanged from the hand-rolled version; works with any
4
+ * OpenAI-compat endpoint (api.openai.com, DeepSeek, OpenRouter, Anyscale,
5
+ * vLLM, Ollama-OpenAI shim, etc.) via the baseUrl override.
6
+ */
7
+
8
+ import { createOpenAI } from '@ai-sdk/openai';
9
+ import { jsonSchema, streamText, type ModelMessage } from 'ai';
1
10
  import type { ContentBlock, Message } from '../types';
2
11
  import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, StopReason } from './types';
3
12
 
4
- // Minimal OpenAI-compatible Chat Completions types — covers what we use.
5
- interface OAIToolCall {
6
- index?: number;
7
- id?: string;
8
- type?: 'function';
9
- function?: { name?: string; arguments?: string };
10
- }
11
-
12
- interface OAITool {
13
- type: 'function';
14
- function: {
15
- name: string;
16
- description: string;
17
- parameters: Record<string, unknown>;
18
- };
19
- }
20
-
21
- type OAIMessage =
22
- | { role: 'system'; content: string }
23
- | { role: 'user'; content: string }
24
- | { role: 'assistant'; content: string | null; tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[] }
25
- | { role: 'tool'; tool_call_id: string; content: string };
26
-
27
- interface OAIChunk {
28
- choices?: {
29
- index?: number;
30
- delta?: {
31
- role?: string;
32
- content?: string | null;
33
- tool_calls?: OAIToolCall[];
34
- };
35
- finish_reason?: string | null;
36
- }[];
37
- }
38
-
39
- function defaultBase(baseUrl: string): string {
40
- return (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
41
- }
42
-
43
- function historyToApi(history: Message[]): OAIMessage[] {
44
- const out: OAIMessage[] = [];
13
+ function historyToModelMessages(history: Message[]): ModelMessage[] {
14
+ const out: ModelMessage[] = [];
45
15
  for (const m of history) {
46
16
  if (m.role === 'user') {
47
- // A user message in our model can be plain text OR a vehicle for tool_result blocks.
48
17
  const toolResults = m.blocks.filter((b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result');
49
18
  if (toolResults.length > 0) {
50
- for (const r of toolResults) {
51
- out.push({ role: 'tool', tool_call_id: r.tool_use_id, content: r.content });
52
- }
53
- } else {
54
- const text = m.blocks
55
- .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
56
- .map((b) => b.text)
57
- .join('\n');
58
- if (text.length > 0) out.push({ role: 'user', content: text });
19
+ out.push({
20
+ role: 'tool',
21
+ content: toolResults.map((r) => ({
22
+ type: 'tool-result' as const,
23
+ toolCallId: r.tool_use_id,
24
+ toolName: '',
25
+ output: { type: 'text', value: r.content },
26
+ })),
27
+ });
28
+ continue;
59
29
  }
60
- } else {
61
- // assistant
62
30
  const text = m.blocks
63
31
  .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
64
- .map((b) => b.text)
65
- .join('');
66
- const tcs = m.blocks
67
- .filter((b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use')
68
- .map((b) => ({
69
- id: b.id,
70
- type: 'function' as const,
71
- function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
72
- }));
73
- if (text.length === 0 && tcs.length === 0) continue;
74
- out.push({
75
- role: 'assistant',
76
- content: text.length > 0 ? text : null,
77
- ...(tcs.length > 0 ? { tool_calls: tcs } : {}),
78
- });
32
+ .map((b) => b.text).join('\n');
33
+ if (text.length > 0) out.push({ role: 'user', content: text });
34
+ } else {
35
+ const parts: any[] = [];
36
+ for (const b of m.blocks) {
37
+ if (b.type === 'text' && b.text.length > 0) {
38
+ parts.push({ type: 'text', text: b.text });
39
+ } else if (b.type === 'tool_use') {
40
+ parts.push({
41
+ type: 'tool-call',
42
+ toolCallId: b.id,
43
+ toolName: b.name,
44
+ input: b.input ?? {},
45
+ });
46
+ }
47
+ }
48
+ if (parts.length > 0) out.push({ role: 'assistant', content: parts });
79
49
  }
80
50
  }
81
51
  return out;
82
52
  }
83
53
 
84
- function mapStop(r: string | null | undefined): StopReason {
54
+ function mapStop(r: string | undefined): StopReason {
85
55
  switch (r) {
86
- case 'stop':
87
- return 'end_turn';
88
- case 'tool_calls':
89
- case 'function_call':
90
- return 'tool_use';
91
- case 'length':
92
- return 'max_tokens';
93
- case 'content_filter':
94
- return 'refusal';
95
- default:
96
- return 'other';
56
+ case 'stop': return 'end_turn';
57
+ case 'tool-calls': return 'tool_use';
58
+ case 'length': return 'max_tokens';
59
+ case 'content-filter': return 'refusal';
60
+ case 'error': return 'error';
61
+ default: return 'other';
97
62
  }
98
63
  }
99
64
 
100
65
  export const openaiAdapter: LlmAdapter = {
101
66
  async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
102
- const url = defaultBase(req.baseUrl) + '/chat/completions';
67
+ const client = createOpenAI({
68
+ apiKey: req.apiKey,
69
+ baseURL: req.baseUrl || undefined,
70
+ });
103
71
 
104
- const tools: OAITool[] = req.tools.map((t) => ({
105
- type: 'function',
106
- function: {
107
- name: t.name,
72
+ const tools: Record<string, any> = {};
73
+ for (const t of req.tools) {
74
+ tools[t.name] = {
108
75
  description: t.description,
109
- parameters: t.input_schema,
110
- },
111
- }));
112
-
113
- const body: Record<string, unknown> = {
114
- model: req.model,
115
- stream: true,
116
- max_tokens: req.maxTokens,
117
- messages: [{ role: 'system', content: req.system }, ...historyToApi(req.history)],
118
- };
119
- if (tools.length > 0) {
120
- body.tools = tools;
76
+ inputSchema: jsonSchema(t.input_schema),
77
+ };
121
78
  }
122
79
 
123
- const resp = await fetch(url, {
124
- method: 'POST',
125
- headers: {
126
- 'content-type': 'application/json',
127
- ...(req.apiKey ? { Authorization: `Bearer ${req.apiKey}` } : {}),
128
- },
129
- body: JSON.stringify(body),
80
+ const result = streamText({
81
+ model: client(req.model),
82
+ system: req.system,
83
+ messages: historyToModelMessages(req.history),
84
+ tools,
85
+ maxOutputTokens: req.maxTokens,
130
86
  });
131
87
 
132
- if (!resp.ok || !resp.body) {
133
- const text = await resp.text().catch(() => '');
134
- throw new Error(`LLM ${resp.status}: ${text.slice(0, 400)}`);
135
- }
136
-
137
- const reader = resp.body.getReader();
138
- const decoder = new TextDecoder();
139
-
140
- let buffer = '';
141
- let accumText = '';
142
- // Tool call accumulators keyed by index (OpenAI streams partial JSON for arguments).
143
- const toolByIndex = new Map<number, { id: string; name: string; argsRaw: string }>();
144
- let finishReason: string | null | undefined;
145
-
146
- while (true) {
147
- const { value, done } = await reader.read();
148
- if (done) break;
149
- buffer += decoder.decode(value, { stream: true });
150
-
151
- // SSE events are separated by blank lines; each event has one or more lines starting with "data: ".
152
- const events = buffer.split('\n\n');
153
- buffer = events.pop() ?? '';
154
-
155
- for (const rawEvent of events) {
156
- for (const line of rawEvent.split('\n')) {
157
- if (!line.startsWith('data:')) continue;
158
- const payload = line.slice(5).trim();
159
- if (!payload || payload === '[DONE]') continue;
160
- let chunk: OAIChunk;
161
- try {
162
- chunk = JSON.parse(payload) as OAIChunk;
163
- } catch {
164
- continue;
165
- }
166
- const choice = chunk.choices?.[0];
167
- if (!choice) continue;
168
- if (choice.finish_reason) finishReason = choice.finish_reason;
169
- const delta = choice.delta;
170
- if (!delta) continue;
171
- if (typeof delta.content === 'string' && delta.content.length > 0) {
172
- accumText += delta.content;
173
- cb.onTextDelta(delta.content);
174
- }
175
- if (delta.tool_calls) {
176
- for (const tc of delta.tool_calls) {
177
- const idx = tc.index ?? 0;
178
- let agg = toolByIndex.get(idx);
179
- if (!agg) {
180
- agg = { id: tc.id ?? '', name: tc.function?.name ?? '', argsRaw: '' };
181
- toolByIndex.set(idx, agg);
182
- }
183
- if (tc.id && !agg.id) agg.id = tc.id;
184
- if (tc.function?.name && !agg.name) agg.name = tc.function.name;
185
- if (tc.function?.arguments) agg.argsRaw += tc.function.arguments;
186
- }
187
- }
188
- }
189
- }
190
- }
191
-
192
88
  const content: ContentBlock[] = [];
193
- if (accumText.length > 0) content.push({ type: 'text', text: accumText });
194
-
195
- // Sort tool calls by index for stable ordering.
196
- const tcKeys = Array.from(toolByIndex.keys()).sort((a, b) => a - b);
197
- for (const idx of tcKeys) {
198
- const agg = toolByIndex.get(idx);
199
- if (!agg) continue;
200
- let input: unknown = {};
201
- if (agg.argsRaw.length > 0) {
202
- try {
203
- input = JSON.parse(agg.argsRaw);
204
- } catch {
205
- input = { _raw: agg.argsRaw };
89
+ let textBuf = '';
90
+ for await (const part of result.fullStream) {
91
+ if (part.type === 'text-delta') {
92
+ textBuf += part.text;
93
+ cb.onTextDelta(part.text);
94
+ } else if (part.type === 'tool-call') {
95
+ if (textBuf.length > 0) {
96
+ content.push({ type: 'text', text: textBuf });
97
+ textBuf = '';
206
98
  }
99
+ cb.onToolUse({ id: part.toolCallId, name: part.toolName, input: part.input });
100
+ content.push({ type: 'tool_use', id: part.toolCallId, name: part.toolName, input: part.input });
101
+ } else if (part.type === 'error') {
102
+ throw new Error(`OpenAI stream error: ${String((part as any).error)}`);
207
103
  }
208
- const id = agg.id || `call_${idx}`;
209
- cb.onToolUse({ id, name: agg.name, input });
210
- content.push({ type: 'tool_use', id, name: agg.name, input });
211
104
  }
105
+ if (textBuf.length > 0) content.push({ type: 'text', text: textBuf });
212
106
 
107
+ const finishReason = await result.finishReason;
213
108
  return { stopReason: mapStop(finishReason), content };
214
109
  },
215
110
  };
@@ -37,6 +37,183 @@ export type BuiltinHandler = (input: unknown) => Promise<string>;
37
37
 
38
38
  const BUILTINS: Record<string, BuiltinHandler> = {
39
39
  get_current_time: async () => new Date().toISOString(),
40
+
41
+ // Trigger a pipeline workflow defined under flows/<name>.yaml. Mirrors
42
+ // the same MCP tool used by Claude Code tasks (forge-mcp-server.ts), but
43
+ // available directly inside the chat agent so users can say "run the
44
+ // mantis-bug-fix pipeline for bug 1234" and have the agent dispatch it.
45
+ //
46
+ // Call without args first to see workflows + their input schemas — saves
47
+ // a round-trip of guessing field names. Schema includes which fields are
48
+ // required (no default) vs optional (have default) so the agent can omit
49
+ // optional ones rather than passing wrong placeholder values.
50
+ trigger_pipeline: async (input) => {
51
+ const params = (input as { workflow?: string; input?: Record<string, unknown> } | undefined) || {};
52
+ const { listWorkflows, startPipeline, getPipeline } = await import('../pipeline');
53
+ if (!params.workflow) {
54
+ const workflows = listWorkflows();
55
+ if (workflows.length === 0) return 'No workflows found. Create one in <dataDir>/flows/.';
56
+ const lines: string[] = ['Available workflows (only pass the marked-required inputs; optional ones use their default):', ''];
57
+ for (const w of workflows) {
58
+ lines.push(`• ${w.name}${w.description ? ' — ' + w.description.split('\n')[0].slice(0, 160) : ''}`);
59
+ const entries = Object.entries(w.input || {});
60
+ if (entries.length === 0) {
61
+ lines.push(' Inputs: (none)');
62
+ } else {
63
+ for (const [name, spec] of entries) {
64
+ // spec may be a string (legacy description-only) or an object.
65
+ const isObj = spec && typeof spec === 'object';
66
+ const desc = isObj ? (spec as any).description : (typeof spec === 'string' ? spec : '');
67
+ const type = isObj ? ((spec as any).type || 'string') : 'string';
68
+ const required = isObj ? !!(spec as any).required : false;
69
+ const hasDefault = isObj && Object.prototype.hasOwnProperty.call(spec, 'default');
70
+ const def = hasDefault ? (spec as any).default : undefined;
71
+ const star = required ? '*' : '';
72
+ const defNote = hasDefault
73
+ ? ` [default: ${String(def).split('\n')[0].slice(0, 60)}${String(def).length > 60 ? '…' : ''}]`
74
+ : '';
75
+ const descShort = desc ? ` — ${String(desc).split('\n')[0].slice(0, 120)}` : '';
76
+ lines.push(` - ${name}${star} (${type})${defNote}${descShort}`);
77
+ }
78
+ }
79
+ lines.push('');
80
+ }
81
+ lines.push('Required inputs are marked with *. Optional ones with defaults will use the default if you omit them — DO NOT pass empty strings or guesses for those.');
82
+ return lines.join('\n');
83
+ }
84
+ // Pre-flight schema validation. The pipeline orchestrator currently
85
+ // silently accepts unknown input keys and lets required fields default to
86
+ // empty — leading to "status: done" no-op runs. Surface those mistakes
87
+ // here so the LLM (or any caller) gets a structured error instead of a
88
+ // misleading success. Workflow-side validation (B in the plan) is deferred
89
+ // because it'd affect schedules.
90
+ const workflows = listWorkflows();
91
+ const wf = workflows.find((w: any) => w.name === params.workflow);
92
+ if (!wf) {
93
+ return `Unknown workflow: "${params.workflow}". Available: ${workflows.map((w: any) => w.name).join(', ') || '(none)'}. Call trigger_pipeline() with no args to see schemas.`;
94
+ }
95
+ const declared = wf.input || {};
96
+ const declaredKeys = new Set(Object.keys(declared));
97
+ const requiredKeys: string[] = [];
98
+ for (const [k, spec] of Object.entries(declared)) {
99
+ if (spec && typeof spec === 'object') {
100
+ const s = spec as any;
101
+ const hasDefault = Object.prototype.hasOwnProperty.call(s, 'default');
102
+ if (s.required && !hasDefault) requiredKeys.push(k);
103
+ }
104
+ }
105
+ const givenKeys = Object.keys(params.input || {});
106
+ const unknown = givenKeys.filter((k) => !declaredKeys.has(k));
107
+ if (unknown.length > 0) {
108
+ return `Unknown input fields for "${params.workflow}": ${unknown.join(', ')}. This workflow only accepts: ${[...declaredKeys].join(', ')}. NOTE: input field names are snake_case (e.g. bug_id) — they are NOT the same as the bash variable names inside pipeline scripts (e.g. BUG_ID). Consider memory_remember_block({key: "rule.pipeline_input_naming", value: "Forge pipeline input fields are exact, snake_case, declared in the workflow yaml. Never invent uppercase / bash-variable names like BUG_ID, BASE, PROJECT_PATH.", pinned: true}) to save this lesson.`;
109
+ }
110
+ const missing = requiredKeys.filter((k) => {
111
+ const v = (params.input || {})[k];
112
+ return v == null || (typeof v === 'string' && v.trim() === '');
113
+ });
114
+ if (missing.length > 0) {
115
+ return `Missing required input fields for "${params.workflow}": ${missing.join(', ')}. All required fields (no defaults): ${requiredKeys.join(', ') || '(none)'}. Re-call with these provided.`;
116
+ }
117
+
118
+ // Coerce numbers/booleans to strings (pipeline orchestrator expects Record<string, string>).
119
+ const stringInput: Record<string, string> = {};
120
+ for (const [k, v] of Object.entries(params.input || {})) {
121
+ stringInput[k] = v == null ? '' : typeof v === 'string' ? v : String(v);
122
+ }
123
+ const pipeline = startPipeline(params.workflow, stringInput);
124
+ let line = `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})`;
125
+ if (pipeline.status === 'failed') {
126
+ const fresh = getPipeline(pipeline.id) || pipeline;
127
+ const errs: string[] = [];
128
+ for (const [nid, n] of Object.entries(fresh.nodes || {})) {
129
+ if ((n as any).error) errs.push(`${nid}: ${(n as any).error}`);
130
+ }
131
+ if (errs.length > 0) line += `\nFailure(s): ${errs.join(' | ').slice(0, 500)}`;
132
+ } else if (pipeline.status === 'done') {
133
+ // For for_each workflows, a "done" with zero iterations is the silent
134
+ // failure mode (empty source). Warn the LLM explicitly so it doesn't
135
+ // claim success.
136
+ const fresh = getPipeline(pipeline.id) || pipeline;
137
+ const forEach = (fresh as any).forEach;
138
+ if (forEach && typeof forEach === 'object') {
139
+ const iters = Array.isArray(forEach.iterations) ? forEach.iterations.length : 0;
140
+ const total = typeof forEach.total === 'number' ? forEach.total : iters;
141
+ if (total === 0) {
142
+ line += '\n⚠ Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
143
+ }
144
+ }
145
+ } else {
146
+ line += '. Watch progress in the Pipelines view.';
147
+ }
148
+ return line;
149
+ },
150
+
151
+ // Surface Forge's local context (projects + agents + skills) so the chat
152
+ // agent can pick valid values for inputs like trigger_pipeline.input.project
153
+ // without guessing. Cheap call — read-only directory + DB lookups.
154
+ list_forge_context: async () => {
155
+ const out: string[] = [];
156
+ try {
157
+ const { scanProjects } = await import('../projects');
158
+ const projects = scanProjects();
159
+ out.push('Forge projects (use the name as input.project for tasks / pipelines):');
160
+ if (projects.length === 0) out.push(' (none — configure project roots in Settings)');
161
+ for (const p of projects) {
162
+ out.push(` - ${p.name}${p.hasClaudeMd ? ' [has CLAUDE.md]' : ''}${p.language ? ' [' + p.language + ']' : ''}`);
163
+ }
164
+ } catch (e) {
165
+ out.push(`(failed to list projects: ${(e as Error).message})`);
166
+ }
167
+ out.push('');
168
+ try {
169
+ const { loadSettings } = await import('../settings');
170
+ const settings = loadSettings();
171
+ const agents = settings.agents || {};
172
+ out.push('Agent profiles (CLI agents; use the id as dispatch_task.agent):');
173
+ const ids = Object.keys(agents);
174
+ if (ids.length === 0) out.push(' (none configured)');
175
+ for (const id of ids) {
176
+ const a = agents[id] as any;
177
+ out.push(` - ${id}${a?.name ? ' (' + a.name + ')' : ''}`);
178
+ }
179
+ } catch (e) {
180
+ out.push(`(failed to list agents: ${(e as Error).message})`);
181
+ }
182
+ out.push('');
183
+ try {
184
+ const { listSkills } = await import('../skills');
185
+ const skills = listSkills();
186
+ out.push('Skills (available to dispatch_task / pipeline via auto-install):');
187
+ if (skills.length === 0) out.push(' (none)');
188
+ for (const s of skills) {
189
+ out.push(` - ${s.name}${s.description ? ' — ' + s.description.slice(0, 80) : ''}`);
190
+ }
191
+ } catch (e) {
192
+ out.push(`(failed to list skills: ${(e as Error).message})`);
193
+ }
194
+ return out.join('\n');
195
+ },
196
+
197
+ // Dispatch a one-shot background task. Agent + skills optional; project is
198
+ // required (defaults to 'scratch' if not given). Returns the task id; the
199
+ // caller can ask "what's the status of task <id>?" later — we don't block.
200
+ dispatch_task: async (input) => {
201
+ const params = (input as { project?: string; prompt?: string; agent?: string } | undefined) || {};
202
+ if (!params.prompt) return 'dispatch_task failed: prompt is required';
203
+ const { getProjectInfo, SCRATCH_PROJECT_NAME } = await import('../projects');
204
+ const projectName = params.project?.trim() || SCRATCH_PROJECT_NAME;
205
+ const project = getProjectInfo(projectName);
206
+ if (!project) return `dispatch_task failed: project "${projectName}" not found`;
207
+ const { createTask } = await import('../task-manager');
208
+ const task = createTask({
209
+ projectName: project.name,
210
+ projectPath: project.path,
211
+ prompt: params.prompt,
212
+ conversationId: '',
213
+ agent: params.agent || undefined,
214
+ });
215
+ return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
216
+ },
40
217
  };
41
218
 
42
219
  export interface BuiltinToolDef {
@@ -51,6 +228,50 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
51
228
  description: 'Get the current local time as an ISO 8601 string. Use whenever the user asks about "now" or "today".',
52
229
  input_schema: { type: 'object', properties: {} },
53
230
  },
231
+ {
232
+ name: 'trigger_pipeline',
233
+ description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. If the pipeline fails immediately, the response includes the validation error so you can fix the inputs and retry.',
234
+ input_schema: {
235
+ type: 'object',
236
+ properties: {
237
+ workflow: {
238
+ type: 'string',
239
+ description: 'Workflow name (file basename of flows/<name>.yaml). Omit to list workflows + schemas.',
240
+ },
241
+ input: {
242
+ type: 'object',
243
+ description: 'Pipeline input fields as a flat object. Pass ONLY required fields (marked * in the list response) and optional fields the user explicitly named. Omit optional fields to use their defaults.',
244
+ },
245
+ },
246
+ },
247
+ },
248
+ {
249
+ name: 'list_forge_context',
250
+ description: "Return the current Forge instance's local context: project names (use these as input.project for pipelines / dispatch_task), agent profile ids, and installed skills. Call this whenever the user references a project / agent / skill by name and you need to validate the name OR when picking defaults for trigger_pipeline / dispatch_task. No arguments.",
251
+ input_schema: { type: 'object', properties: {} },
252
+ },
253
+ {
254
+ name: 'dispatch_task',
255
+ description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns immediately with the task id; the task runs in the background and the user can check the Tasks view for output.',
256
+ input_schema: {
257
+ type: 'object',
258
+ properties: {
259
+ prompt: {
260
+ type: 'string',
261
+ description: 'The full instruction text Claude should execute. Be specific about what files to read, what to produce.',
262
+ },
263
+ project: {
264
+ type: 'string',
265
+ description: 'Forge project name (the working directory). Defaults to "scratch" if omitted. Use "scratch" for connector-only / no-filesystem tasks.',
266
+ },
267
+ agent: {
268
+ type: 'string',
269
+ description: 'Optional agent id override. Omit to use the project default.',
270
+ },
271
+ },
272
+ required: ['prompt'],
273
+ },
274
+ },
54
275
  ];
55
276
 
56
277
  // ─── Connector dispatch ──────────────────────────────────
package/lib/pipeline.ts CHANGED
@@ -871,6 +871,25 @@ export function startPipeline(
871
871
  const workflow = getWorkflow(workflowName);
872
872
  if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
873
873
 
874
+ // Apply declared defaults for any field the caller didn't pass. UI callers
875
+ // (Schedule form, Fire dialog) already pre-fill all fields with their
876
+ // defaults at form-load time, so for them this merge is a no-op. Sparse-input
877
+ // callers (chat trigger_pipeline, MCP, direct API) only send what the user
878
+ // explicitly named; without this merge multi-line defaults like
879
+ // mr_body_template / user_prompt come through as empty → "{{input.X}}"
880
+ // renders to '' → MR body empty / triage prompt empty.
881
+ //
882
+ // Semantics: ONLY fill when the key is missing from the input map. An
883
+ // empty-string value is respected as "intentionally cleared" so schedule
884
+ // users who clear a field in the form keep that behaviour.
885
+ for (const [key, spec] of Object.entries(workflow.input || {})) {
886
+ if (Object.prototype.hasOwnProperty.call(input, key)) continue;
887
+ if (!spec || typeof spec !== 'object') continue;
888
+ const def = (spec as WorkflowInputFieldSpec).default;
889
+ if (def == null) continue;
890
+ input = { ...input, [key]: String(def) };
891
+ }
892
+
874
893
  // Conversation mode — separate execution path
875
894
  if (workflow.type === 'conversation' && workflow.conversation) {
876
895
  return startConversationPipeline(workflow, input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {