@asobacloud/nehanda 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +444 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- package/templates/validate_test.sh +55 -0
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
import readline from 'node:readline'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
4
|
+
import { resolveAnthropicCredentials } from './auth.mjs'
|
|
5
|
+
import { resolveWireModel, anthropicBaseUrl, omitToolChoice } from './modelConfig.mjs'
|
|
6
|
+
import { runHooks } from './hookplane.mjs'
|
|
7
|
+
import { appendEntry, transcriptToAnthropicMessages, transcriptToOpenAIMessages, makeUserPayload, makeAssistantPayload, makeToolResultPayload, applyEpistemicFilter } from './transcript.mjs'
|
|
8
|
+
import { evaluatePermission } from './permissions.mjs'
|
|
9
|
+
import { executeBuiltinTool, anthropicToolDefinitions, openAICompatToolDefinitions, LOCAL_CORE_TOOLS, EXPLORE_ONLY_TOOLS, dynamicTools, getMcpToolDefinitions } from './tools.mjs'
|
|
10
|
+
import { streamOpenAIChatCompletion } from './openaiCompat.mjs'
|
|
11
|
+
import { withTransaction } from './store.mjs'
|
|
12
|
+
import { getPhase, setPhase, canTransition } from './workflow.mjs'
|
|
13
|
+
import { buildJevSystem2Messages } from './compact.mjs'
|
|
14
|
+
|
|
15
|
+
// ─── Dynamic tool prompt injection ───────────────────────────
|
|
16
|
+
|
|
17
|
+
function getMandatoryToolInstructions(phase) {
|
|
18
|
+
const instructions = []
|
|
19
|
+
for (const [, config] of dynamicTools) {
|
|
20
|
+
if (config.phases?.mandatory_in?.includes(phase) && config.prompt?.mandatory_instruction) {
|
|
21
|
+
instructions.push(`- MANDATORY: ${config.prompt.mandatory_instruction}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (instructions.length === 0) return ''
|
|
25
|
+
return `- After tests pass, you MUST run these mandatory safety checks. Do NOT skip them. If any fail, fix the issues and re-run.\n${instructions.join('\n')}\n`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getAvailableToolHints() {
|
|
29
|
+
const hints = []
|
|
30
|
+
for (const [, config] of dynamicTools) {
|
|
31
|
+
if (config.prompt?.available_hint) {
|
|
32
|
+
hints.push(`- ${config.name}: ${config.prompt.available_hint}`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (hints.length === 0) return ''
|
|
36
|
+
return `\n# Safety & Analysis Tools\nYou have access to these tools for code review and safety analysis:\n${hints.join('\n')}\n\n`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Phase system prompts ─────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const PHASE_SYSTEM = {
|
|
42
|
+
explore: (cwd, provider, model, date, os, shell, onaInstructions) => `You are ona, an autonomous CLI agent.
|
|
43
|
+
|
|
44
|
+
You are in EXPLORE phase. Your ONLY job is to find and read files relevant to the task.
|
|
45
|
+
- Use Glob, Grep, and Read to explore the codebase
|
|
46
|
+
- Read every file that could be relevant to the task
|
|
47
|
+
- Do NOT write plans, suggest steps, ask questions, or produce any output other than tool calls
|
|
48
|
+
- When you have read everything relevant, output EXACTLY this single line and nothing else:
|
|
49
|
+
Exploration complete.
|
|
50
|
+
|
|
51
|
+
# Environment
|
|
52
|
+
- Working directory: ${cwd}
|
|
53
|
+
- Platform: ${os} | Shell: ${shell} | Date: ${date}
|
|
54
|
+
- Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`,
|
|
55
|
+
|
|
56
|
+
planning: (cwd, provider, model, date, os, shell, onaInstructions) => `You are ona, an autonomous CLI agent.
|
|
57
|
+
|
|
58
|
+
You are in PLANNING phase. File summaries from exploration are provided below.
|
|
59
|
+
Write your plan based ONLY on those summaries. Do NOT use any tools.
|
|
60
|
+
|
|
61
|
+
Write your plan using EXACTLY this template and nothing else:
|
|
62
|
+
|
|
63
|
+
## Objective
|
|
64
|
+
<one sentence describing what you will produce>
|
|
65
|
+
|
|
66
|
+
## Plan
|
|
67
|
+
1. <exact command, script invocation, or file write — not vague intent>
|
|
68
|
+
2. <exact command, script invocation, or file write>
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
Rules: every step must name exact paths and commands extracted from the summaries. Never use "review", "reference", or "based on documentation" as a step.
|
|
72
|
+
|
|
73
|
+
## Proof of Success
|
|
74
|
+
<specific file or output that will exist when done>
|
|
75
|
+
|
|
76
|
+
# Environment
|
|
77
|
+
- Working directory: ${cwd}
|
|
78
|
+
- Platform: ${os} | Shell: ${shell} | Date: ${date}
|
|
79
|
+
- Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`,
|
|
80
|
+
|
|
81
|
+
implement: (cwd, provider, model, date, os, shell, onaInstructions) => `You are ona, an autonomous CLI agent.
|
|
82
|
+
|
|
83
|
+
You are in IMPLEMENT phase. Execute the approved plan exactly.
|
|
84
|
+
- Use Write or Edit to create/modify files
|
|
85
|
+
- Use Bash to run commands if needed
|
|
86
|
+
- When all implementation work is done, write a one-line summary as your final response: "Done: <what you did>"
|
|
87
|
+
- Do not ask questions. Do not explain. Just implement.
|
|
88
|
+
|
|
89
|
+
# Environment
|
|
90
|
+
- Working directory: ${cwd}
|
|
91
|
+
- Platform: ${os} | Shell: ${shell} | Date: ${date}
|
|
92
|
+
- Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`,
|
|
93
|
+
|
|
94
|
+
test: (cwd, provider, model, date, os, shell, onaInstructions) => {
|
|
95
|
+
const mandatoryChecks = getMandatoryToolInstructions('test')
|
|
96
|
+
return `You are ona, an autonomous CLI agent.
|
|
97
|
+
|
|
98
|
+
You are in TEST phase.
|
|
99
|
+
- Run the project tests using Bash
|
|
100
|
+
- If tests fail, fix the code and re-run
|
|
101
|
+
${mandatoryChecks}
|
|
102
|
+
- When all tests pass AND all mandatory checks pass, write a one-line summary as your final response: "Tests passed: <what was verified>"
|
|
103
|
+
- Do not ask questions. Do not explain. Just run the tests and checks.
|
|
104
|
+
|
|
105
|
+
# Environment
|
|
106
|
+
- Working directory: ${cwd}
|
|
107
|
+
- Platform: ${os} | Shell: ${shell} | Date: ${date}
|
|
108
|
+
- Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
default: (cwd, provider, model, date, os, shell, onaInstructions) => {
|
|
112
|
+
const availableHints = getAvailableToolHints()
|
|
113
|
+
return `You are ona, an interactive CLI agent that helps users with software engineering tasks.
|
|
114
|
+
|
|
115
|
+
- Proactively use tools. Do not ask for permission.
|
|
116
|
+
- Be concise. Lead with actions, not reasoning.
|
|
117
|
+
- Use Read to understand files before editing.
|
|
118
|
+
- For discovery — listing files, finding paths, searching file contents — use Glob and Grep, not Bash. Reserve Bash for actual execution: running commands, tests, installs, git operations.
|
|
119
|
+
${availableHints}
|
|
120
|
+
# Environment
|
|
121
|
+
- Working directory: ${cwd}
|
|
122
|
+
- Platform: ${os} | Shell: ${shell} | Date: ${date}
|
|
123
|
+
- Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Phase user message templates ────────────────────────────
|
|
128
|
+
|
|
129
|
+
function buildExploreUserMessage(userRequest) {
|
|
130
|
+
return `Task: ${userRequest}
|
|
131
|
+
|
|
132
|
+
Explore systematically to find files relevant to this specific task:
|
|
133
|
+
|
|
134
|
+
1. Glob pattern "**/*.md" to find documentation files — read any whose names relate to the task
|
|
135
|
+
2. Glob pattern "**/*.py" to find Python scripts — read any that look relevant to the task
|
|
136
|
+
3. Glob pattern "**/*.sh" to find shell scripts — read any that look relevant to the task
|
|
137
|
+
4. Use Grep to search for keywords from the task if globs don't surface the right files
|
|
138
|
+
5. Read only files that appeared in glob/grep results and are plausibly relevant to this specific task
|
|
139
|
+
|
|
140
|
+
IMPORTANT: Your task is "${userRequest}". Only read files related to this task.
|
|
141
|
+
|
|
142
|
+
Use the working directory as the base for all globs.
|
|
143
|
+
Do NOT guess paths. Only read files that appear in glob results.
|
|
144
|
+
When done reading, output EXACTLY:
|
|
145
|
+
Exploration complete.`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildPlanningUserMessage(summaries, userRequest) {
|
|
149
|
+
let fileSection
|
|
150
|
+
if (!summaries || summaries.length === 0) {
|
|
151
|
+
fileSection = '(no files were successfully read during exploration)'
|
|
152
|
+
} else {
|
|
153
|
+
fileSection = summaries.map(({ path, header, summary }) => {
|
|
154
|
+
const parts = [`### ${path}`]
|
|
155
|
+
if (summary) parts.push(`**Relevance:** ${summary}`)
|
|
156
|
+
if (header) parts.push('**File header (first 50 lines):**\n```\n' + header + '\n```')
|
|
157
|
+
return parts.join('\n')
|
|
158
|
+
}).join('\n\n')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `Exploration and summarization are complete. Here is what each file contains that is relevant to the task:
|
|
162
|
+
|
|
163
|
+
${fileSection}
|
|
164
|
+
|
|
165
|
+
Original task: ${userRequest}
|
|
166
|
+
|
|
167
|
+
Write your plan using ONLY the content above. Do NOT reference files not listed here. Do NOT use any tools.
|
|
168
|
+
|
|
169
|
+
## Objective
|
|
170
|
+
<one sentence: what you will produce>
|
|
171
|
+
|
|
172
|
+
## Plan
|
|
173
|
+
1. <concrete step — must specify exact file path, command, or code change>
|
|
174
|
+
2. <concrete step>
|
|
175
|
+
|
|
176
|
+
RULES FOR PLAN STEPS:
|
|
177
|
+
- Every step must be executable: name the exact script, command, or file to write
|
|
178
|
+
- If running a script, include the exact invocation with arguments drawn from the summaries above
|
|
179
|
+
- If writing a file, name the target path and describe its contents
|
|
180
|
+
- NEVER use vague verbs: "review", "understand", "reference", "use as reference", "based on the documentation"
|
|
181
|
+
|
|
182
|
+
## Proof of Success
|
|
183
|
+
<specific file or output that will exist when done>
|
|
184
|
+
|
|
185
|
+
Do NOT use any tools. Write the plan only.`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildImplementUserMessage(planContent) {
|
|
189
|
+
return `Approved plan:
|
|
190
|
+
|
|
191
|
+
${planContent}
|
|
192
|
+
|
|
193
|
+
Implement the plan now using Write, Edit, and Bash as needed.`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildTestUserMessage() {
|
|
197
|
+
return `Implementation accepted. You MUST run the tests now using Bash before doing anything else. Execute the appropriate test command for this project (e.g. python hello_world.py, pytest, npm test, etc.).`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildImplementSummaryPrompt(toolResults) {
|
|
201
|
+
return `You just completed the implementation. Here is what actually happened:
|
|
202
|
+
|
|
203
|
+
${toolResults}
|
|
204
|
+
|
|
205
|
+
Fill in this summary using ONLY the information above — do not invent anything:
|
|
206
|
+
|
|
207
|
+
## Files Changed
|
|
208
|
+
- <file path>: <what was done to it>
|
|
209
|
+
|
|
210
|
+
## Result
|
|
211
|
+
<one sentence: what was produced>`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildTestSummaryPrompt(toolResults) {
|
|
215
|
+
return `You just ran the tests. Here is what actually happened:
|
|
216
|
+
|
|
217
|
+
${toolResults}
|
|
218
|
+
|
|
219
|
+
Fill in this summary using ONLY the information above — do not invent anything:
|
|
220
|
+
|
|
221
|
+
## Tests Run
|
|
222
|
+
<command that was executed>
|
|
223
|
+
|
|
224
|
+
## Result
|
|
225
|
+
<one sentence: passed or failed, what was verified>`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function buildSystemPrompt(cwd, provider, model, phase, onaInstructions, injectedTools = null) {
|
|
229
|
+
const os = process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux'
|
|
230
|
+
const shell = process.env.SHELL?.split('/').pop() || 'sh'
|
|
231
|
+
const date = new Date().toISOString().split('T')[0]
|
|
232
|
+
const ticks = String.fromCharCode(96, 96, 96)
|
|
233
|
+
const LANGUAGE_LOCK = 'Always respond in English.'
|
|
234
|
+
|
|
235
|
+
const builder = PHASE_SYSTEM[phase] || PHASE_SYSTEM.default
|
|
236
|
+
let prompt = builder(cwd, provider, model, date, os, shell, onaInstructions)
|
|
237
|
+
|
|
238
|
+
// Pin output language for multilingual models (e.g. Qwen) that otherwise code-switch.
|
|
239
|
+
if (!prompt.includes(LANGUAGE_LOCK)) {
|
|
240
|
+
const nl = prompt.indexOf('\n')
|
|
241
|
+
prompt = nl === -1
|
|
242
|
+
? `${prompt}\n\n${LANGUAGE_LOCK}`
|
|
243
|
+
: `${prompt.slice(0, nl)}\n\n${LANGUAGE_LOCK}${prompt.slice(nl)}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (injectedTools && injectedTools.length > 0) {
|
|
247
|
+
prompt += `\n\n# MANDATORY: Tool Calling Format
|
|
248
|
+
Your environment requires JSON tool calls in markdown blocks:
|
|
249
|
+
${ticks}json
|
|
250
|
+
{"tool": "ToolName", "input": {"param": "value"}}
|
|
251
|
+
${ticks}
|
|
252
|
+
|
|
253
|
+
Available Tools:
|
|
254
|
+
${injectedTools.map(t => `- ${t.function.name}: ${t.function.description} (Schema: ${JSON.stringify(t.function.parameters.properties)})`).join('\n')}`
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return prompt
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildXmlToolInstructions(tools, phase) {
|
|
261
|
+
if (phase === 'planning' || !tools || tools.length === 0) return ''
|
|
262
|
+
|
|
263
|
+
const KEY_TOOLS = ['Bash', 'Read', 'Glob']
|
|
264
|
+
const examples = tools
|
|
265
|
+
.filter(t => KEY_TOOLS.includes(t.function?.name))
|
|
266
|
+
.map(t => {
|
|
267
|
+
const fn = t.function
|
|
268
|
+
const req = fn.parameters?.required || []
|
|
269
|
+
const args = Object.entries(fn.parameters?.properties || {})
|
|
270
|
+
.filter(([k]) => req.includes(k))
|
|
271
|
+
.map(([k, v]) => ` "${k}": "${v.type === 'integer' ? '0' : '...'}"`)
|
|
272
|
+
.join(',\n')
|
|
273
|
+
return `[TOOL_CALL]\n{"name": "${fn.name}", "arguments": {\n${args}\n}}\n[/TOOL_CALL]`
|
|
274
|
+
})
|
|
275
|
+
.join('\n\n')
|
|
276
|
+
|
|
277
|
+
const allNames = tools.filter(t => t.function?.name).map(t => t.function.name).join(', ')
|
|
278
|
+
|
|
279
|
+
return `\n\n# Tool Calling Format\n\nTo call a tool, emit a [TOOL_CALL] block containing a valid JSON object. Do NOT use XML tags inside the block.\n\nExamples:\n\n${examples}\n\nAll available tools: ${allNames}\n\nRules:\n- Emit strictly [TOOL_CALL] {"name": "...", "arguments": {...}} [/TOOL_CALL]. Do NOT use <arguments> or XML tags.\n- Escape all double quotes inside command strings (e.g., "echo \\"---LOG---\\"").\n- One tool call per response. After tool use is complete, write your final answer as plain text.`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
function filterToolsByPhase(tools, phase) {
|
|
284
|
+
if (phase === 'explore') {
|
|
285
|
+
return tools.filter(t => {
|
|
286
|
+
const name = t.name || t.function?.name
|
|
287
|
+
return EXPLORE_ONLY_TOOLS.has(name)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (phase === 'planning') {
|
|
291
|
+
return []
|
|
292
|
+
}
|
|
293
|
+
return tools
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sessionTokens = new Map()
|
|
297
|
+
|
|
298
|
+
export function getSessionTokens(sessionId) {
|
|
299
|
+
return sessionTokens.get(sessionId) || { input: 0, output: 0, calls: 0 }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function trackTokens(sessionId, inputTokens, outputTokens) {
|
|
303
|
+
const prev = sessionTokens.get(sessionId) || { input: 0, output: 0, calls: 0 }
|
|
304
|
+
sessionTokens.set(sessionId, {
|
|
305
|
+
input: prev.input + (inputTokens || 0),
|
|
306
|
+
output: prev.output + (outputTokens || 0),
|
|
307
|
+
calls: prev.calls + 1,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function makeAnthropicClient(cred) {
|
|
312
|
+
const baseURL = anthropicBaseUrl()
|
|
313
|
+
if (cred.mode === 'bearer') return new Anthropic({ authToken: cred.secret, apiKey: null, baseURL, defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' } })
|
|
314
|
+
if (cred.mode === 'api_key') return new Anthropic({ apiKey: cred.secret, baseURL })
|
|
315
|
+
throw new Error('No credentials for the active model provider')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function activeProvider(settings) {
|
|
319
|
+
return settings?.model_config?.provider || 'claude_code_subscription'
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function toolsForProvider(provider, settings, nativeToolSupport = true) {
|
|
323
|
+
const coreOnly = provider === 'lm_studio_local' || (provider === 'ollama' && !nativeToolSupport)
|
|
324
|
+
const mcpTools = await getMcpToolDefinitions(settings)
|
|
325
|
+
|
|
326
|
+
const anthropicBase = coreOnly
|
|
327
|
+
? anthropicToolDefinitions().filter(t => LOCAL_CORE_TOOLS.has(t.name))
|
|
328
|
+
: anthropicToolDefinitions()
|
|
329
|
+
|
|
330
|
+
const openaiBase = coreOnly
|
|
331
|
+
? openAICompatToolDefinitions().filter(t => LOCAL_CORE_TOOLS.has(t.function.name))
|
|
332
|
+
: openAICompatToolDefinitions()
|
|
333
|
+
|
|
334
|
+
// Append MCP tools as Anthropic-format and OpenAI-compat-format entries
|
|
335
|
+
const anthropicMcp = mcpTools // already Anthropic format from getMcpToolDefinitions
|
|
336
|
+
const openaiMcp = mcpTools.map(t => ({
|
|
337
|
+
type: 'function',
|
|
338
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
339
|
+
}))
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
anthropic: [...anthropicBase, ...anthropicMcp],
|
|
343
|
+
openai: [...openaiBase, ...openaiMcp],
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const ollamaCapabilityCache = new Map()
|
|
348
|
+
|
|
349
|
+
async function detectOllamaToolSupport(baseUrl, modelName) {
|
|
350
|
+
const key = `${baseUrl}::${modelName}`
|
|
351
|
+
if (ollamaCapabilityCache.has(key)) return ollamaCapabilityCache.get(key)
|
|
352
|
+
|
|
353
|
+
let result = true
|
|
354
|
+
try {
|
|
355
|
+
const apiBase = baseUrl.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
|
|
356
|
+
const resp = await fetch(`${apiBase}/api/show`, {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
headers: { 'Content-Type': 'application/json' },
|
|
359
|
+
body: JSON.stringify({ name: modelName }),
|
|
360
|
+
signal: AbortSignal.timeout(3000)
|
|
361
|
+
})
|
|
362
|
+
if (!resp.ok) { result = true }
|
|
363
|
+
else {
|
|
364
|
+
const data = await resp.json()
|
|
365
|
+
result = !!(
|
|
366
|
+
data.template?.includes('.Tools') ||
|
|
367
|
+
data.modelfile?.includes('.Tools') ||
|
|
368
|
+
data.capabilities?.includes('tools')
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
result = true
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
ollamaCapabilityCache.set(key, result)
|
|
376
|
+
return result
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function mapAnthropicContent(content) {
|
|
380
|
+
return content.map(b => {
|
|
381
|
+
if (b.type === 'text') return { type: 'text', text: b.text }
|
|
382
|
+
if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} }
|
|
383
|
+
return null
|
|
384
|
+
}).filter(Boolean)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function classifyError(e) {
|
|
388
|
+
const msg = String(e?.message || e).toLowerCase()
|
|
389
|
+
if (msg.includes('401')) return 'authentication_failed'
|
|
390
|
+
if (msg.includes('429')) return 'rate_limit'
|
|
391
|
+
if (msg.includes('404')) return 'model_not_found'
|
|
392
|
+
if (msg.includes('500') || msg.includes('503')) return 'server_error'
|
|
393
|
+
return 'unknown'
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function askHuman(io, q) {
|
|
397
|
+
if (!process.stdin.isTTY) return true
|
|
398
|
+
if (io?.ask) return (await io.ask(q)).trim().toLowerCase().startsWith('y')
|
|
399
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
400
|
+
return new Promise(resolve => {
|
|
401
|
+
rl.question(q, ans => { rl.close(); resolve(/^y(es)?$/i.test(String(ans || '').trim())) })
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function parseManualToolCalls(text) {
|
|
406
|
+
const calls = []
|
|
407
|
+
const ticks = String.fromCharCode(96, 96, 96)
|
|
408
|
+
const pattern = ticks + '(?:json)?\\s*(\\{[\\s\\S]*?\\})\\s*' + ticks
|
|
409
|
+
const blockRegex = new RegExp(pattern, 'g')
|
|
410
|
+
let match
|
|
411
|
+
while ((match = blockRegex.exec(text)) !== null) {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(match[1].trim())
|
|
414
|
+
if (parsed.tool) {
|
|
415
|
+
calls.push({ type: 'tool_use', id: `manual-${Math.random().toString(36).slice(2, 9)}`, name: parsed.tool, input: parsed.input || {} })
|
|
416
|
+
}
|
|
417
|
+
} catch { }
|
|
418
|
+
}
|
|
419
|
+
return calls
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function parseXmlToolCalls(text, knownToolNames = null) {
|
|
423
|
+
if (!text || !text.includes('[TOOL_CALL]')) return []
|
|
424
|
+
const calls = []
|
|
425
|
+
|
|
426
|
+
let cursor = 0
|
|
427
|
+
while (true) {
|
|
428
|
+
const startIdx = text.indexOf('[TOOL_CALL]', cursor)
|
|
429
|
+
if (startIdx === -1) break
|
|
430
|
+
|
|
431
|
+
const contentStart = startIdx + '[TOOL_CALL]'.length
|
|
432
|
+
let endIdx = text.indexOf('[/TOOL_CALL]', contentStart)
|
|
433
|
+
if (endIdx === -1) {
|
|
434
|
+
endIdx = text.length
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
cursor = endIdx + (endIdx === text.length ? 0 : '[/TOOL_CALL]'.length)
|
|
438
|
+
|
|
439
|
+
let raw = text.slice(contentStart, endIdx).trim()
|
|
440
|
+
if (!raw) continue
|
|
441
|
+
|
|
442
|
+
raw = raw.replace(/<\/?arguments>/g, '').replace(/<\/?tool_call>/g, '').trim()
|
|
443
|
+
|
|
444
|
+
const firstBrace = raw.indexOf('{')
|
|
445
|
+
const lastBrace = raw.lastIndexOf('}')
|
|
446
|
+
|
|
447
|
+
if (firstBrace === -1) continue
|
|
448
|
+
|
|
449
|
+
if (lastBrace > firstBrace) {
|
|
450
|
+
raw = raw.slice(firstBrace, lastBrace + 1)
|
|
451
|
+
} else {
|
|
452
|
+
raw = raw.slice(firstBrace)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Repair the `{"ToolName", "arguments": {...}}` shorthand — model drops
|
|
456
|
+
// the "name": key and leaves the tool name as a bare leading string.
|
|
457
|
+
raw = raw.replace(/^\{\s*"([A-Za-z_][\w-]*)"\s*,/, '{"name":"$1",')
|
|
458
|
+
|
|
459
|
+
let parsed = null
|
|
460
|
+
try {
|
|
461
|
+
parsed = JSON.parse(raw)
|
|
462
|
+
} catch {
|
|
463
|
+
let openBraces = 0, openBrackets = 0
|
|
464
|
+
for (const ch of raw) {
|
|
465
|
+
if (ch === '{') openBraces++
|
|
466
|
+
else if (ch === '}') openBraces--
|
|
467
|
+
else if (ch === '[') openBrackets++
|
|
468
|
+
else if (ch === ']') openBrackets--
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let repaired = raw
|
|
472
|
+
while (openBrackets > 0) { repaired += ']'; openBrackets-- }
|
|
473
|
+
while (openBraces > 0) { repaired += '}'; openBraces-- }
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
parsed = JSON.parse(repaired)
|
|
477
|
+
} catch {
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (!parsed || typeof parsed !== 'object') continue
|
|
483
|
+
|
|
484
|
+
const name = parsed.name || parsed.tool
|
|
485
|
+
if (!name || typeof name !== 'string') continue
|
|
486
|
+
if (knownToolNames && knownToolNames.size > 0 && !knownToolNames.has(name)) continue
|
|
487
|
+
|
|
488
|
+
const args = parsed.arguments || parsed.input
|
|
489
|
+
const input = (args && typeof args === 'object' && !Array.isArray(args)) ? args : {}
|
|
490
|
+
|
|
491
|
+
calls.push({
|
|
492
|
+
type: 'tool_use',
|
|
493
|
+
id: `tc-${Math.random().toString(36).slice(2, 9)}`,
|
|
494
|
+
name,
|
|
495
|
+
input,
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return calls
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function sanitizeModelText(text) {
|
|
503
|
+
if (!text) return text
|
|
504
|
+
return text
|
|
505
|
+
.replace(/<[||]begin[▁_]of[▁_]sentence[||]>/g, '')
|
|
506
|
+
.replace(/<[||]end[▁_]of[▁_]sentence[||]>/g, '')
|
|
507
|
+
.replace(/<\|im_start\|>\w*/g, '')
|
|
508
|
+
.replace(/<\|im_end\|>/g, '')
|
|
509
|
+
.trim()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function sanitizeMessages(messages) {
|
|
513
|
+
return messages.map(m => {
|
|
514
|
+
if (typeof m.content === 'string') {
|
|
515
|
+
return { ...m, content: sanitizeModelText(m.content) }
|
|
516
|
+
}
|
|
517
|
+
if (Array.isArray(m.content)) {
|
|
518
|
+
return {
|
|
519
|
+
...m,
|
|
520
|
+
content: m.content.map(block => {
|
|
521
|
+
if (typeof block === 'string') return sanitizeModelText(block)
|
|
522
|
+
if (block?.text) return { ...block, text: sanitizeModelText(block.text) }
|
|
523
|
+
if (block?.content && typeof block.content === 'string') return { ...block, content: sanitizeModelText(block.content) }
|
|
524
|
+
return block
|
|
525
|
+
}),
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return m
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const CONTENT_CAP_CHARS = 6000
|
|
533
|
+
const HEADER_LINES = 50
|
|
534
|
+
|
|
535
|
+
function extractFilesRead(db, sessionId) {
|
|
536
|
+
const rows = db.prepare(`
|
|
537
|
+
SELECT entry_type, payload_json FROM transcript_entries
|
|
538
|
+
WHERE session_id = ? ORDER BY sequence ASC
|
|
539
|
+
`).all(sessionId)
|
|
540
|
+
|
|
541
|
+
const toolCalls = new Map()
|
|
542
|
+
for (const row of rows) {
|
|
543
|
+
try {
|
|
544
|
+
const payload = JSON.parse(row.payload_json)
|
|
545
|
+
if (row.entry_type === 'assistant') {
|
|
546
|
+
for (const b of payload.content || []) {
|
|
547
|
+
if (b.type === 'tool_use' && b.name === 'Read') {
|
|
548
|
+
toolCalls.set(b.id, b.input?.path || b.input?.file_path || '(unknown)')
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} catch { }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const seen = new Set()
|
|
556
|
+
const files = []
|
|
557
|
+
|
|
558
|
+
for (const row of rows) {
|
|
559
|
+
try {
|
|
560
|
+
const payload = JSON.parse(row.payload_json)
|
|
561
|
+
if (row.entry_type === 'tool_result') {
|
|
562
|
+
const filePath = toolCalls.get(payload.tool_use_id)
|
|
563
|
+
if (!filePath || payload.is_error || seen.has(filePath)) continue
|
|
564
|
+
seen.add(filePath)
|
|
565
|
+
|
|
566
|
+
const raw = typeof payload.content === 'string'
|
|
567
|
+
? payload.content
|
|
568
|
+
: (payload.content?.[0]?.text ?? '')
|
|
569
|
+
|
|
570
|
+
const content = raw.length > CONTENT_CAP_CHARS
|
|
571
|
+
? raw.slice(0, CONTENT_CAP_CHARS) + `\n... [truncated — ${raw.length} chars total]`
|
|
572
|
+
: raw
|
|
573
|
+
|
|
574
|
+
const header = raw.split('\n').slice(0, HEADER_LINES).join('\n')
|
|
575
|
+
|
|
576
|
+
files.push({ path: filePath, content, header })
|
|
577
|
+
}
|
|
578
|
+
} catch { }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return files
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function summarizeFilesAnthropicForTask(client, rt, filesRead, userRequest, io) {
|
|
585
|
+
if (!filesRead.length) return []
|
|
586
|
+
const summaries = []
|
|
587
|
+
|
|
588
|
+
for (const { path, content, header } of filesRead) {
|
|
589
|
+
if (io.spinner) io.spinner.start(`Summarising ${path.split('/').pop()}`)
|
|
590
|
+
const prompt = `File path: ${path}
|
|
591
|
+
|
|
592
|
+
File content:
|
|
593
|
+
\`\`\`
|
|
594
|
+
${content}
|
|
595
|
+
\`\`\`
|
|
596
|
+
|
|
597
|
+
Task: ${userRequest}
|
|
598
|
+
|
|
599
|
+
In 4-6 sentences, what does this file tell you that is directly relevant to accomplishing the task?
|
|
600
|
+
Focus on: exact script names, function signatures, argument names, expected file paths, output locations, prerequisites, and any constraints.
|
|
601
|
+
If this file is not relevant to the task, say "Not relevant" and nothing else.`
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const response = await client.messages.create({
|
|
605
|
+
model: rt._model,
|
|
606
|
+
max_tokens: 512,
|
|
607
|
+
messages: [{ role: 'user', content: prompt }],
|
|
608
|
+
})
|
|
609
|
+
const summary = sanitizeModelText(response.content?.[0]?.text || '')
|
|
610
|
+
trackTokens(rt.sessionId, response.usage?.input_tokens, response.usage?.output_tokens)
|
|
611
|
+
if (summary && !/^not relevant/i.test(summary)) {
|
|
612
|
+
summaries.push({ path, header, summary })
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
summaries.push({ path, header, summary: '(summarization failed — see header)' })
|
|
616
|
+
}
|
|
617
|
+
if (io.spinner) io.spinner.stop()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return summaries
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function summarizeFilesOpenAICompatForTask(baseUrl, apiKey, rt, filesRead, userRequest, io) {
|
|
624
|
+
if (!filesRead.length) return []
|
|
625
|
+
const summaries = []
|
|
626
|
+
|
|
627
|
+
for (const { path, content, header } of filesRead) {
|
|
628
|
+
if (io.spinner) io.spinner.start(`Summarising ${path.split('/').pop()}`)
|
|
629
|
+
const prompt = `File path: ${path}
|
|
630
|
+
|
|
631
|
+
File content:
|
|
632
|
+
\`\`\`
|
|
633
|
+
${content}
|
|
634
|
+
\`\`\`
|
|
635
|
+
|
|
636
|
+
Task: ${userRequest}
|
|
637
|
+
|
|
638
|
+
In 4-6 sentences, what does this file tell you that is directly relevant to accomplishing the task?
|
|
639
|
+
Focus on: exact script names, function signatures, argument names, expected file paths, output locations, prerequisites, and any constraints.
|
|
640
|
+
If this file is not relevant to the task, say "Not relevant" and nothing else.`
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
const out = await streamOpenAIChatCompletion({
|
|
644
|
+
baseUrl, apiKey,
|
|
645
|
+
model: rt._model,
|
|
646
|
+
messages: [{ role: 'user', content: prompt }],
|
|
647
|
+
tools: [],
|
|
648
|
+
io,
|
|
649
|
+
bufferOutput: true,
|
|
650
|
+
maxTokens: 512,
|
|
651
|
+
numCtx: rt.settings?.model_config?.num_ctx,
|
|
652
|
+
})
|
|
653
|
+
const summary = sanitizeModelText(out.bufferedText || '')
|
|
654
|
+
if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
|
|
655
|
+
if (summary && !/^not relevant/i.test(summary)) {
|
|
656
|
+
summaries.push({ path, header, summary })
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
summaries.push({ path, header, summary: '(summarization failed — see header)' })
|
|
660
|
+
}
|
|
661
|
+
if (io.spinner) io.spinner.stop()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return summaries
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function exploreGateAnthropic(db, rt, io, client, userRequest) {
|
|
668
|
+
const filesRead = extractFilesRead(db, rt.sessionId)
|
|
669
|
+
io.println(`✓ Exploration complete. Summarising ${filesRead.length} file(s)...`)
|
|
670
|
+
|
|
671
|
+
const summaries = await summarizeFilesAnthropicForTask(client, rt, filesRead, userRequest, io)
|
|
672
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildPlanningUserMessage(summaries, userRequest)))
|
|
673
|
+
setPhase(db, rt.conversationId, 'planning', 'explore_complete')
|
|
674
|
+
io.println('✓ Summarisation complete. Entering planning phase.')
|
|
675
|
+
return true
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, userRequest) {
|
|
679
|
+
const filesRead = extractFilesRead(db, rt.sessionId)
|
|
680
|
+
io.println(`✓ Exploration complete. Summarising ${filesRead.length} file(s)...`)
|
|
681
|
+
|
|
682
|
+
const summaries = await summarizeFilesOpenAICompatForTask(baseUrl, apiKey, rt, filesRead, userRequest, io)
|
|
683
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildPlanningUserMessage(summaries, userRequest)))
|
|
684
|
+
setPhase(db, rt.conversationId, 'planning', 'explore_complete')
|
|
685
|
+
io.println('✓ Summarisation complete. Entering planning phase.')
|
|
686
|
+
return true
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function planGate(db, rt, io, planText) {
|
|
690
|
+
const clean = sanitizeModelText(planText)
|
|
691
|
+
if (!clean) {
|
|
692
|
+
io.println('\n[planning] Model produced no plan text. Try again.')
|
|
693
|
+
return false
|
|
694
|
+
}
|
|
695
|
+
io.println(`\n${'─'.repeat(60)}\n${clean}\n${'─'.repeat(60)}`)
|
|
696
|
+
const answer = io.ask ? await io.ask('Approve this plan? [y/N] ') : 'y'
|
|
697
|
+
if (!/^y(es)?$/i.test(String(answer || '').trim())) {
|
|
698
|
+
io.println('Plan rejected. Revise your request or try again.')
|
|
699
|
+
setPhase(db, rt.conversationId, 'idle', 'plan_rejected')
|
|
700
|
+
return false
|
|
701
|
+
}
|
|
702
|
+
const hash = randomUUID().slice(0, 16)
|
|
703
|
+
withTransaction(db, () => {
|
|
704
|
+
db.prepare(`INSERT INTO plans(conversation_id, content, hash, status, approved_at) VALUES (?,?,?,'approved',datetime('now'))`).run(rt.conversationId, clean, hash)
|
|
705
|
+
})
|
|
706
|
+
setPhase(db, rt.conversationId, 'implement', 'plan_approved')
|
|
707
|
+
io.println('✓ Plan approved. Entering implementation phase.')
|
|
708
|
+
return true
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function implementGate(db, rt, io, summaryText) {
|
|
712
|
+
if (summaryText) io.println(`\n${'─'.repeat(60)}\n${summaryText}\n${'─'.repeat(60)}`)
|
|
713
|
+
const answer = io.ask ? await io.ask('Accept implementation and run tests? [y/N] ') : 'y'
|
|
714
|
+
if (!/^y(es)?$/i.test(String(answer || '').trim())) {
|
|
715
|
+
io.println('Implementation rejected. Continuing in implement phase.')
|
|
716
|
+
return false
|
|
717
|
+
}
|
|
718
|
+
setPhase(db, rt.conversationId, 'test', 'implementation_accepted')
|
|
719
|
+
io.println('✓ Implementation accepted. Entering test phase.')
|
|
720
|
+
return true
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function testGate(db, rt, io, summaryText) {
|
|
724
|
+
if (summaryText) io.println(`\n${'─'.repeat(60)}\n${summaryText}\n${'─'.repeat(60)}`)
|
|
725
|
+
const answer = io.ask ? await io.ask('Accept test results and complete task? [y/N] ') : 'y'
|
|
726
|
+
if (!/^y(es)?$/i.test(String(answer || '').trim())) {
|
|
727
|
+
io.println('Test results rejected. Continuing in test phase.')
|
|
728
|
+
return false
|
|
729
|
+
}
|
|
730
|
+
setPhase(db, rt.conversationId, 'idle', 'tests_accepted')
|
|
731
|
+
io.println('✓ Task complete. Returning to idle.')
|
|
732
|
+
return true
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function extractRecentToolResults(db, sessionId) {
|
|
736
|
+
const rows = db.prepare(`
|
|
737
|
+
SELECT entry_type, payload_json FROM transcript_entries
|
|
738
|
+
WHERE session_id = ? ORDER BY sequence ASC
|
|
739
|
+
`).all(sessionId)
|
|
740
|
+
|
|
741
|
+
let lastUserIdx = -1
|
|
742
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
743
|
+
if (rows[i].entry_type === 'user') { lastUserIdx = i; break }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const startIdx = lastUserIdx >= 0 ? lastUserIdx + 1 : 0
|
|
747
|
+
const toolCalls = new Map()
|
|
748
|
+
const results = []
|
|
749
|
+
|
|
750
|
+
for (let i = startIdx; i < rows.length; i++) {
|
|
751
|
+
const row = rows[i]
|
|
752
|
+
try {
|
|
753
|
+
const payload = JSON.parse(row.payload_json)
|
|
754
|
+
if (row.entry_type === 'assistant') {
|
|
755
|
+
for (const b of payload.content || []) {
|
|
756
|
+
if (b.type === 'tool_use') toolCalls.set(b.id, b.name)
|
|
757
|
+
}
|
|
758
|
+
} else if (row.entry_type === 'tool_result') {
|
|
759
|
+
const name = toolCalls.get(payload.tool_use_id) || 'tool'
|
|
760
|
+
const content = typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content)
|
|
761
|
+
const status = payload.is_error ? '✗' : '✓'
|
|
762
|
+
results.push(`${status} ${name}: ${content.slice(0, 300)}`)
|
|
763
|
+
}
|
|
764
|
+
} catch { }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (!results.length) {
|
|
768
|
+
for (const row of rows) {
|
|
769
|
+
try {
|
|
770
|
+
const payload = JSON.parse(row.payload_json)
|
|
771
|
+
if (row.entry_type === 'assistant') {
|
|
772
|
+
for (const b of payload.content || []) {
|
|
773
|
+
if (b.type === 'tool_use') toolCalls.set(b.id, b.name)
|
|
774
|
+
}
|
|
775
|
+
} else if (row.entry_type === 'tool_result') {
|
|
776
|
+
const name = toolCalls.get(payload.tool_use_id) || 'tool'
|
|
777
|
+
const content = typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content)
|
|
778
|
+
const status = payload.is_error ? '✗' : '✓'
|
|
779
|
+
if (!results.some(r => r.includes(content.slice(0, 50)))) {
|
|
780
|
+
results.push(`${status} ${name}: ${content.slice(0, 300)}`)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} catch { }
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return results.length ? results.join('\n') : '(no tool results recorded)'
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt) {
|
|
791
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(summaryPrompt))
|
|
792
|
+
if (io.spinner) io.spinner.start('Summarising')
|
|
793
|
+
|
|
794
|
+
const currentPhase = getPhase(db, rt.conversationId)
|
|
795
|
+
const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions)
|
|
796
|
+
const messages = applyEpistemicFilter(transcriptToAnthropicMessages(db, rt.sessionId), currentPhase)
|
|
797
|
+
|
|
798
|
+
let bufferedText = ''
|
|
799
|
+
try {
|
|
800
|
+
const stream = await client.messages.stream({ model: rt._model, max_tokens: 1024, system, messages, tools: [] })
|
|
801
|
+
for await (const ev of stream) {
|
|
802
|
+
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
|
|
803
|
+
bufferedText += ev.delta.text
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const final = await stream.finalMessage()
|
|
807
|
+
trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
|
|
808
|
+
appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(mapAnthropicContent(final.content || [])))
|
|
809
|
+
} catch (e) {
|
|
810
|
+
if (io.spinner) io.spinner.stop()
|
|
811
|
+
return null
|
|
812
|
+
}
|
|
813
|
+
if (io.spinner) io.spinner.stop()
|
|
814
|
+
return sanitizeModelText(bufferedText)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, summaryPrompt) {
|
|
818
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(summaryPrompt))
|
|
819
|
+
if (io.spinner) io.spinner.start('Summarising')
|
|
820
|
+
|
|
821
|
+
const currentPhase = getPhase(db, rt.conversationId)
|
|
822
|
+
const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions)
|
|
823
|
+
const rawMessages = transcriptToOpenAIMessages(db, rt.sessionId)
|
|
824
|
+
const messages = [{ role: 'system', content: systemPrompt }, ...sanitizeMessages(rawMessages)]
|
|
825
|
+
|
|
826
|
+
let result = null
|
|
827
|
+
try {
|
|
828
|
+
const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: [], io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx })
|
|
829
|
+
if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
|
|
830
|
+
appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(out.assistantBlocks))
|
|
831
|
+
result = sanitizeModelText(out.bufferedText)
|
|
832
|
+
} catch {
|
|
833
|
+
}
|
|
834
|
+
if (io.spinner) io.spinner.stop()
|
|
835
|
+
return result
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
|
|
839
|
+
for (;;) {
|
|
840
|
+
if (io.spinner) io.spinner.start('Thinking')
|
|
841
|
+
const currentPhase = getPhase(db, rt.conversationId)
|
|
842
|
+
const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions)
|
|
843
|
+
const filteredTools = filterToolsByPhase(tools, currentPhase)
|
|
844
|
+
const rawAnthropic = applyEpistemicFilter(transcriptToAnthropicMessages(db, rt.sessionId), currentPhase)
|
|
845
|
+
const messages = await buildJevSystem2Messages(db, rt, rawAnthropic)
|
|
846
|
+
|
|
847
|
+
let assistantBlocks = [], stopReason = null, bufferedText = ''
|
|
848
|
+
try {
|
|
849
|
+
const stream = await client.messages.stream({ model: rt._model, max_tokens: 8192, system, messages, tools: filteredTools })
|
|
850
|
+
for await (const ev of stream) {
|
|
851
|
+
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
|
|
852
|
+
bufferedText += ev.delta.text
|
|
853
|
+
}
|
|
854
|
+
if (ev.type === 'content_block_start' && ev.content_block?.type === 'tool_use') {
|
|
855
|
+
if (io.spinner) io.spinner.stop()
|
|
856
|
+
if (io.onToolStart) io.onToolStart(ev.content_block.name)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const final = await stream.finalMessage()
|
|
860
|
+
stopReason = final.stop_reason
|
|
861
|
+
assistantBlocks = mapAnthropicContent(final.content || [])
|
|
862
|
+
trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
|
|
863
|
+
} catch (e) {
|
|
864
|
+
if (io.spinner) io.spinner.stop()
|
|
865
|
+
await runHooks(db, hookRt, 'StopFailure', { error: classifyError(e), error_details: e.message })
|
|
866
|
+
io.println(`\n[model error] ${e.message}`)
|
|
867
|
+
return null
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (io.spinner) io.spinner.stop()
|
|
871
|
+
appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(assistantBlocks))
|
|
872
|
+
|
|
873
|
+
const toolUses = assistantBlocks.filter(b => b.type === 'tool_use')
|
|
874
|
+
|
|
875
|
+
if (!toolUses.length || stopReason === 'end_turn') {
|
|
876
|
+
return bufferedText
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
await executeToolUses(db, rt, io, hookRt, toolUses)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase) {
|
|
884
|
+
let activeTools = injectedTools ? [] : allTools
|
|
885
|
+
let currentInjectedTools = injectedTools
|
|
886
|
+
|
|
887
|
+
for (;;) {
|
|
888
|
+
if (io.spinner) io.spinner.start('Thinking')
|
|
889
|
+
|
|
890
|
+
const currentPhase = getPhase(db, rt.conversationId)
|
|
891
|
+
const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions, currentInjectedTools)
|
|
892
|
+
const filteredTools = filterToolsByPhase(activeTools, currentPhase)
|
|
893
|
+
const xmlInstructions = (omitToolChoice(provider) || provider === 'nehanda')
|
|
894
|
+
? buildXmlToolInstructions(filteredTools, currentPhase)
|
|
895
|
+
: ''
|
|
896
|
+
const fullSystemPrompt = systemPrompt + xmlInstructions
|
|
897
|
+
const rawMessages = transcriptToOpenAIMessages(db, rt.sessionId)
|
|
898
|
+
const filteredMessages = applyEpistemicFilter(sanitizeMessages(rawMessages), currentPhase)
|
|
899
|
+
const jevMessages = await buildJevSystem2Messages(db, rt, filteredMessages)
|
|
900
|
+
|
|
901
|
+
const messages = [{ role: 'system', content: fullSystemPrompt }, ...jevMessages]
|
|
902
|
+
|
|
903
|
+
let assistantBlocks, bufferedText = null, finishReason = null
|
|
904
|
+
try {
|
|
905
|
+
// When xmlInstructions is non-empty, tool calling is being driven entirely by
|
|
906
|
+
// prompt-injected [TOOL_CALL] text parsing (parseXmlToolCalls), specifically to
|
|
907
|
+
// route around vLLM's --tool-call-parser qwen3_xml stop-token bug (see README).
|
|
908
|
+
// Sending the native `tools` schema in the same request re-engages vLLM's own
|
|
909
|
+
// --enable-auto-tool-choice machinery (see deploy_sagemaker.sh SM_VLLM_ENABLE_AUTO_TOOL_CHOICE)
|
|
910
|
+
// on top of the prompt-injected instructions, producing malformed hybrid output
|
|
911
|
+
// like {"Glob", "arguments": {...}} instead of {"name": "Glob", "arguments": {...}}.
|
|
912
|
+
const toolsForRequest = xmlInstructions ? [] : filteredTools
|
|
913
|
+
const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: toolsForRequest, io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, omitToolChoice: omitToolChoice(provider), useStream: !omitToolChoice(provider) })
|
|
914
|
+
assistantBlocks = out.assistantBlocks
|
|
915
|
+
bufferedText = out.bufferedText
|
|
916
|
+
finishReason = out.finishReason
|
|
917
|
+
|
|
918
|
+
const text = assistantBlocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
|
919
|
+
const knownToolNames = new Set(filteredTools.map(t => t.function?.name).filter(Boolean))
|
|
920
|
+
const xmlCalls = parseXmlToolCalls(text, knownToolNames)
|
|
921
|
+
if (process.env.DEBUG_NEHANDA) console.log('\n[DEBUG] xmlCalls count:', xmlCalls.length, 'knownToolNames:', Array.from(knownToolNames))
|
|
922
|
+
const manualCalls = xmlCalls.length > 0 ? xmlCalls : parseManualToolCalls(text)
|
|
923
|
+
if (manualCalls.length > 0) {
|
|
924
|
+
if (io.spinner) io.spinner.stop()
|
|
925
|
+
for (const call of manualCalls) {
|
|
926
|
+
if (io.onToolStart) io.onToolStart(call.name)
|
|
927
|
+
}
|
|
928
|
+
assistantBlocks = [...assistantBlocks, ...manualCalls]
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
|
|
932
|
+
} catch (e) {
|
|
933
|
+
if (e.message?.includes('does not support tools') && activeTools.length > 0) {
|
|
934
|
+
if (io.spinner) io.spinner.stop()
|
|
935
|
+
io.println('\n[info] Model switching to manual tool injection...')
|
|
936
|
+
activeTools = []
|
|
937
|
+
currentInjectedTools = allTools
|
|
938
|
+
const cacheKey = `${baseUrl}::${rt._model}`
|
|
939
|
+
ollamaCapabilityCache.set(cacheKey, false)
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
942
|
+
if (io.spinner) io.spinner.stop()
|
|
943
|
+
await runHooks(db, hookRt, 'StopFailure', { error: classifyError(e), error_details: e.message })
|
|
944
|
+
io.println(`\n[model error] ${e.message}`)
|
|
945
|
+
return null
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (io.spinner) io.spinner.stop()
|
|
949
|
+
|
|
950
|
+
if (!assistantBlocks.length) return bufferedText
|
|
951
|
+
|
|
952
|
+
appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(assistantBlocks))
|
|
953
|
+
const toolUses = assistantBlocks.filter(b => b.type === 'tool_use')
|
|
954
|
+
|
|
955
|
+
let displayText = bufferedText || ''
|
|
956
|
+
if (displayText) {
|
|
957
|
+
const ticks = String.fromCharCode(96, 96, 96)
|
|
958
|
+
const pattern = ticks + '(?:json)?\\s*\\{[\\s\\S]*?\\}\\s*' + ticks
|
|
959
|
+
displayText = displayText.replace(new RegExp(pattern, 'g'), '').trim()
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (!toolUses.length) {
|
|
963
|
+
return displayText || bufferedText
|
|
964
|
+
}
|
|
965
|
+
if (finishReason === 'length') {
|
|
966
|
+
return displayText || bufferedText
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
await executeToolUses(db, rt, io, hookRt, toolUses)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function executeToolUses(db, rt, io, hookRt, toolUses) {
|
|
974
|
+
for (const tu of toolUses) {
|
|
975
|
+
const toolName = tu.name, toolUseId = tu.id, toolInput = tu.input || {}
|
|
976
|
+
|
|
977
|
+
const pre = await runHooks(db, hookRt, 'PreToolUse', { tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId })
|
|
978
|
+
if (!pre.ok) continue
|
|
979
|
+
|
|
980
|
+
const decision = evaluatePermission(rt.settings?.permissions, toolName, toolInput, getPhase(db, rt.conversationId))
|
|
981
|
+
if (decision === 'ask') {
|
|
982
|
+
const ok = await askHuman(io, `Allow ${toolName}? [y/N] `)
|
|
983
|
+
if (!ok) {
|
|
984
|
+
appendEntry(db, rt.sessionId, 'tool_result', makeToolResultPayload(toolUseId, 'User denied permission.', true), toolUseId)
|
|
985
|
+
continue
|
|
986
|
+
}
|
|
987
|
+
} else if (decision === 'deny') {
|
|
988
|
+
appendEntry(db, rt.sessionId, 'tool_result', makeToolResultPayload(toolUseId, 'Permission denied by policy.', true), toolUseId)
|
|
989
|
+
continue
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (io.onToolStart) io.onToolStart(toolName)
|
|
993
|
+
const execCtx = { sessionId: rt.sessionId, conversationId: rt.conversationId, cwd: rt.cwd, settings: rt.settings }
|
|
994
|
+
const out = await executeBuiltinTool(db, execCtx, toolName, toolInput, io)
|
|
995
|
+
|
|
996
|
+
if (toolName === 'Bash' && out.newCwd) rt.cwd = out.newCwd
|
|
997
|
+
|
|
998
|
+
withTransaction(db, () => {
|
|
999
|
+
appendEntry(db, rt.sessionId, 'tool_result', makeToolResultPayload(toolUseId, out.content, out.is_error), toolUseId)
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
if (io.onToolResult) io.onToolResult(toolName, out.content, out.is_error)
|
|
1003
|
+
|
|
1004
|
+
await runHooks(db, hookRt, out.is_error ? 'PostToolUseFailure' : 'PostToolUse', {
|
|
1005
|
+
tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId,
|
|
1006
|
+
...(out.is_error ? { error: out.content } : { tool_response: { content: out.content, is_error: out.is_error } }),
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export async function runUserTurn(db, rt, userText, io) {
|
|
1012
|
+
rt._userText = userText
|
|
1013
|
+
const provider = activeProvider(rt.settings)
|
|
1014
|
+
const phase = getPhase(db, rt.conversationId)
|
|
1015
|
+
const hookRt = {
|
|
1016
|
+
sessionId: rt.sessionId, conversationId: rt.conversationId,
|
|
1017
|
+
runtimeDbPath: rt.runtimeDbPath, cwd: rt.cwd,
|
|
1018
|
+
permissionMode: rt.settings?.permissions?.defaultMode ?? 'default',
|
|
1019
|
+
settings: rt.settings,
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const ups = await runHooks(db, hookRt, 'UserPromptSubmit', { prompt: userText })
|
|
1023
|
+
if (!ups.ok) { io.println(`UserPromptSubmit blocked: ${ups.userPrompt?.stderr || 'exit 2'}`); return }
|
|
1024
|
+
|
|
1025
|
+
if (phase === 'idle' && io.ask) {
|
|
1026
|
+
const answer = await io.ask('Use SDLC workflow (plan → implement → test)? [Y/n] ')
|
|
1027
|
+
const useSdlc = !answer.trim() || /^y(es)?$/i.test(answer.trim())
|
|
1028
|
+
if (useSdlc) {
|
|
1029
|
+
setPhase(db, rt.conversationId, 'explore', 'user_confirmed')
|
|
1030
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildExploreUserMessage(userText)))
|
|
1031
|
+
} else {
|
|
1032
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(userText))
|
|
1033
|
+
}
|
|
1034
|
+
} else {
|
|
1035
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(userText))
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
let model
|
|
1039
|
+
try { model = resolveWireModel(rt.settings.model_config) } catch (e) { io.println(`[config] ${e.message}`); return }
|
|
1040
|
+
rt._model = model
|
|
1041
|
+
|
|
1042
|
+
const currentPhase = getPhase(db, rt.conversationId)
|
|
1043
|
+
|
|
1044
|
+
if (['lm_studio_local', 'openai_compatible', 'zhipu', 'ollama', 'nehanda'].includes(provider)) {
|
|
1045
|
+
await runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, currentPhase)
|
|
1046
|
+
} else {
|
|
1047
|
+
await runSdlcAnthropic(db, rt, io, hookRt, model, currentPhase)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
await runHooks(db, hookRt, 'Stop', { stop_hook_active: false })
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function runSdlcAnthropic(db, rt, io, hookRt, model, startPhase) {
|
|
1054
|
+
const cred = resolveAnthropicCredentials({ bareMode: rt.bareMode, apiKeyHelper: rt.settings?.apiKeyHelper ?? null })
|
|
1055
|
+
if (cred.mode === 'none') { io.println('No credentials found.'); return }
|
|
1056
|
+
const client = makeAnthropicClient(cred)
|
|
1057
|
+
const { anthropic: tools } = await toolsForProvider(activeProvider(rt.settings), rt.settings)
|
|
1058
|
+
|
|
1059
|
+
let phase = startPhase
|
|
1060
|
+
|
|
1061
|
+
if (phase === 'idle') {
|
|
1062
|
+
const text = await runAnthropicPhase(db, rt, io, hookRt, client, tools, phase)
|
|
1063
|
+
if (text) io.write(text)
|
|
1064
|
+
return
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
|
|
1068
|
+
const text = await runAnthropicPhase(db, rt, io, hookRt, client, tools, phase)
|
|
1069
|
+
if (text === null) return
|
|
1070
|
+
|
|
1071
|
+
let advanced = false
|
|
1072
|
+
|
|
1073
|
+
if (phase === 'explore') {
|
|
1074
|
+
advanced = await exploreGateAnthropic(db, rt, io, client, rt._userText)
|
|
1075
|
+
} else if (phase === 'planning') {
|
|
1076
|
+
advanced = await planGate(db, rt, io, text)
|
|
1077
|
+
} else if (phase === 'implement') {
|
|
1078
|
+
const toolResults = extractRecentToolResults(db, rt.sessionId)
|
|
1079
|
+
const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildImplementSummaryPrompt(toolResults))
|
|
1080
|
+
advanced = await implementGate(db, rt, io, summary)
|
|
1081
|
+
} else if (phase === 'test') {
|
|
1082
|
+
const toolResults = extractRecentToolResults(db, rt.sessionId)
|
|
1083
|
+
const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildTestSummaryPrompt(toolResults))
|
|
1084
|
+
advanced = await testGate(db, rt, io, summary)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (!advanced) break
|
|
1088
|
+
|
|
1089
|
+
phase = getPhase(db, rt.conversationId)
|
|
1090
|
+
if (phase === 'idle') break
|
|
1091
|
+
|
|
1092
|
+
if (phase === 'implement') {
|
|
1093
|
+
const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
|
|
1094
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildImplementUserMessage(plan?.content || '')))
|
|
1095
|
+
} else if (phase === 'test') {
|
|
1096
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPhase) {
|
|
1102
|
+
let baseUrl, apiKey
|
|
1103
|
+
const savedBaseUrl = rt.settings?.model_config?.base_url?.replace(/\/+$/, '')
|
|
1104
|
+
if (provider === 'ollama') {
|
|
1105
|
+
baseUrl = savedBaseUrl || process.env.OLLAMA_BASE_URL || 'http://localhost:11434/v1'
|
|
1106
|
+
apiKey = 'ollama'
|
|
1107
|
+
} else if (provider === 'nehanda') {
|
|
1108
|
+
baseUrl = savedBaseUrl || process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1'
|
|
1109
|
+
apiKey = rt.settings?.model_config?.api_key || process.env.NEHANDA_API_KEY || ''
|
|
1110
|
+
} else {
|
|
1111
|
+
baseUrl = savedBaseUrl || process.env.OPENAI_BASE_URL
|
|
1112
|
+
apiKey = process.env.OPENAI_API_KEY
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const forceManual = rt.settings?.model_config?.force_manual_tools === true
|
|
1116
|
+
|
|
1117
|
+
let nativeSupport = true
|
|
1118
|
+
if (provider === 'ollama') nativeSupport = await detectOllamaToolSupport(baseUrl, model)
|
|
1119
|
+
|
|
1120
|
+
const { openai: allTools } = await toolsForProvider(provider, rt.settings, nativeSupport)
|
|
1121
|
+
const injectedTools = (forceManual || !nativeSupport) ? allTools : null
|
|
1122
|
+
|
|
1123
|
+
let phase = startPhase
|
|
1124
|
+
|
|
1125
|
+
if (phase === 'idle') {
|
|
1126
|
+
const text = await runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase)
|
|
1127
|
+
if (text) io.write(text)
|
|
1128
|
+
return
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
|
|
1132
|
+
const text = await runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase)
|
|
1133
|
+
if (text === null) return
|
|
1134
|
+
|
|
1135
|
+
let advanced = false
|
|
1136
|
+
|
|
1137
|
+
if (phase === 'explore') {
|
|
1138
|
+
advanced = await exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, rt._userText)
|
|
1139
|
+
} else if (phase === 'planning') {
|
|
1140
|
+
advanced = await planGate(db, rt, io, text)
|
|
1141
|
+
} else if (phase === 'implement') {
|
|
1142
|
+
const toolResults = extractRecentToolResults(db, rt.sessionId)
|
|
1143
|
+
const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildImplementSummaryPrompt(toolResults))
|
|
1144
|
+
advanced = await implementGate(db, rt, io, summary)
|
|
1145
|
+
} else if (phase === 'test') {
|
|
1146
|
+
const toolResults = extractRecentToolResults(db, rt.sessionId)
|
|
1147
|
+
const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildTestSummaryPrompt(toolResults))
|
|
1148
|
+
advanced = await testGate(db, rt, io, summary)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (!advanced) break
|
|
1152
|
+
|
|
1153
|
+
phase = getPhase(db, rt.conversationId)
|
|
1154
|
+
if (phase === 'idle') break
|
|
1155
|
+
|
|
1156
|
+
if (phase === 'implement') {
|
|
1157
|
+
const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
|
|
1158
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildImplementUserMessage(plan?.content || '')))
|
|
1159
|
+
} else if (phase === 'test') {
|
|
1160
|
+
appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|