@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 +8 -12
- package/lib/chat/agent-loop.ts +24 -0
- package/lib/chat/llm/anthropic.ts +128 -78
- package/lib/chat/llm/openai.ts +75 -180
- package/lib/chat/tool-dispatcher.ts +221 -0
- package/lib/pipeline.ts +19 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
# Forge v0.9.
|
|
1
|
+
# Forge v0.9.14
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-27
|
|
4
4
|
|
|
5
|
-
## Changes since v0.9.
|
|
5
|
+
## Changes since v0.9.13
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- feat(
|
|
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.
|
|
15
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.13...v0.9.14
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -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 —
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 =
|
|
122
|
+
const client = makeClient(req.apiKey, req.baseUrl);
|
|
87
123
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
stream.on('text', (delta: string) => cb.onTextDelta(delta));
|
|
132
|
+
inputSchema: jsonSchema(t.input_schema),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
101
135
|
|
|
102
|
-
const
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
164
|
+
const finishReason = await result.finishReason;
|
|
165
|
+
return { stopReason: mapStop(finishReason), content };
|
|
116
166
|
},
|
|
117
167
|
};
|
package/lib/chat/llm/openai.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
type: '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 |
|
|
54
|
+
function mapStop(r: string | undefined): StopReason {
|
|
85
55
|
switch (r) {
|
|
86
|
-
case 'stop':
|
|
87
|
-
|
|
88
|
-
case '
|
|
89
|
-
case '
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
67
|
+
const client = createOpenAI({
|
|
68
|
+
apiKey: req.apiKey,
|
|
69
|
+
baseURL: req.baseUrl || undefined,
|
|
70
|
+
});
|
|
103
71
|
|
|
104
|
-
const tools:
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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