codeply-cli 0.2.0 → 0.3.1
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/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +558 -370
- package/lib/agent.mjs +1627 -160
- package/lib/ai.js +1107 -387
- package/lib/apply-limit.js +33 -51
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -136
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +307 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +80 -22
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1658 -79
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -992
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +4 -3
- package/skills/premium-web-design/SKILL.md +154 -0
package/lib/agent.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codeply agent loop.
|
|
3
3
|
*
|
|
4
|
-
* The ai-proxy exposes a plain chat completion
|
|
4
|
+
* The ai-proxy exposes a plain chat completion - no native function calling -
|
|
5
5
|
* so tool use rides on a tag protocol the model writes into its reply and we
|
|
6
6
|
* parse back out. Tags are used rather than JSON because file content goes in
|
|
7
7
|
* verbatim: no escaping pass to get wrong, and models corrupt long JSON strings
|
|
@@ -11,31 +11,52 @@
|
|
|
11
11
|
* instead of freezing until the whole task finishes.
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'fs';
|
|
14
|
+
import os from 'os';
|
|
14
15
|
import path from 'path';
|
|
15
16
|
import { execSync } from 'child_process';
|
|
16
17
|
import { createRequire } from 'module';
|
|
17
|
-
import { executeTool, TOOL_NEEDS_APPROVAL, walkFiles, resolvePath } from './tools.mjs';
|
|
18
|
+
import { executeTool, TOOL_NEEDS_APPROVAL, walkFiles, resolvePath, formatTodos, isPlanPath } from './tools.mjs';
|
|
19
|
+
import { buildToolSchemas, toNativeMessages, toolCallsToTags, NATIVE_FORMAT, TOOLS_UNSUPPORTED } from './native-tools.mjs';
|
|
18
20
|
|
|
19
21
|
const require = createRequire(import.meta.url);
|
|
20
22
|
const ai = require('./ai.js');
|
|
23
|
+
const mcpLib = require('./mcp.js');
|
|
21
24
|
const skills = require('./skills.js');
|
|
25
|
+
const plugins = require('./plugins.js');
|
|
26
|
+
const rolesLib = require('./subagents.js');
|
|
27
|
+
const config = require('./config.js');
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
// Enough room for a real task plus its verification pass. /goal runs pass a
|
|
30
|
+
// larger budget per iteration (see main.js).
|
|
31
|
+
const MAX_STEPS = 40;
|
|
24
32
|
const MAX_MALFORMED_RETRIES = 3;
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
33
|
+
// How much transcript (system prompt included) is sent per step before older
|
|
34
|
+
// tool results get collapsed. The hosted proxy is a Supabase Edge Function
|
|
35
|
+
// with its own memory ceiling (it returns 546 past it), so Auto stays modest;
|
|
36
|
+
// the system prompt alone is ~40K chars, so this has to leave real room for
|
|
37
|
+
// the conversation or the model loses track of what it already did. User
|
|
38
|
+
// models usually have far larger windows; local Ollama runs at 32K tokens.
|
|
39
|
+
const CONTEXT_CHAR_BUDGET = 64000;
|
|
40
|
+
function contextBudgetFor(route) {
|
|
41
|
+
if (route && route.custom) return route.custom.kind === 'ollama' ? 100000 : 200000;
|
|
42
|
+
if (route && route.auto) return 160000; // Gemma 4 31B on Ollama Cloud has a large window
|
|
43
|
+
return CONTEXT_CHAR_BUDGET;
|
|
44
|
+
}
|
|
29
45
|
|
|
30
46
|
const FORMAT_CORRECTION =
|
|
31
|
-
'[system] Your action block was not in the required form. Do not invent wrappers like <toolcall
|
|
32
|
-
'do not drop the "codeply:" prefix, and keep the underscore in the name.
|
|
33
|
-
'
|
|
47
|
+
'[system] Your action block was not in the required form. Do not invent wrappers like <toolcall> or ' +
|
|
48
|
+
'<|tool_call>, do not drop the "codeply:" prefix, and keep the underscore in the name. There is no native ' +
|
|
49
|
+
'function-calling channel here - even if you have one, it is not connected, so anything you write there is ' +
|
|
50
|
+
'silently discarded and never runs. Write it exactly like this, as literal text in your reply, opening and ' +
|
|
51
|
+
'closing tags complete:\n\n' +
|
|
34
52
|
'<codeply:read_file>\n<path>run.html</path>\n</codeply:read_file>\n\n' +
|
|
35
|
-
'Valid names: list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills
|
|
53
|
+
'Valid names: todo, ask_user, mcp, list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills, fetch_image, ' +
|
|
54
|
+
'browser_check, gmail_send, gmail_search, slack_post_message, vercel_deploy, supabase_create_project, ' +
|
|
55
|
+
'supabase_delete_project, github_create_repo, design_reference_search, view_images, supabase_api, supabase_sql, vercel_api, ' +
|
|
56
|
+
'web_fetch, web_search, apply_patch, plan_exit, plan_enter, lsp.';
|
|
36
57
|
|
|
37
58
|
const TRUNCATION_CORRECTION =
|
|
38
|
-
'[system] That reply got cut off partway through the action block
|
|
59
|
+
'[system] That reply got cut off partway through the action block - the tag syntax was fine, it simply ran out of ' +
|
|
39
60
|
'room before the block closed. This happens when write_file or edit_file tries to carry too much content in one reply. ' +
|
|
40
61
|
"Don't re-explain or apologize for the format, just write less this time: for a new or rewritten file, write_file only " +
|
|
41
62
|
'the first section now and use edit_file to add the rest over one or two more steps; for an edit, split it into smaller ' +
|
|
@@ -43,13 +64,207 @@ const TRUNCATION_CORRECTION =
|
|
|
43
64
|
|
|
44
65
|
const MAX_TRUNCATED_RETRIES = 3;
|
|
45
66
|
|
|
67
|
+
// A reply that CLAIMS a file was written/edited/fixed, in the past tense,
|
|
68
|
+
// while itself containing no action block at all - and with no real
|
|
69
|
+
// write_file/edit_file success anywhere earlier in this same turn - is not
|
|
70
|
+
// "finished", it's a confabulated result. This is a real, observed failure
|
|
71
|
+
// mode on weaker models: they narrate "I have now applied the change" as
|
|
72
|
+
// filler text instead of actually emitting the tag, and the user is left
|
|
73
|
+
// with a chat that describes progress that never happened on disk. Matches
|
|
74
|
+
// only completion language (past tense / "successfully" / "has been"), not
|
|
75
|
+
// forward-looking intent ("I will now update...") - a plan is not a lie.
|
|
76
|
+
// The adverb list between "have" and the verb is deliberately a closed set
|
|
77
|
+
// (now/already/actually/just/finally), not "any word" - "I have TO update"
|
|
78
|
+
// is a statement of necessity, not a completion claim, and must not match.
|
|
79
|
+
const HC_VERB = '(?:applied|updated|written|wrote|edited|fixed|changed|added|created|modified|implemented|built|removed|deleted|replaced|refactored|renamed|rewritten|rewrote|moved|made)';
|
|
80
|
+
const HC_ADVERB = '(?:now|already|actually|just|finally)';
|
|
81
|
+
const HALLUCINATED_COMPLETION = new RegExp(
|
|
82
|
+
`\\bi(?:'ve|\\s+have)\\s+(?:${HC_ADVERB}\\s+){0,2}${HC_VERB}\\b` + // "I have now already applied..."
|
|
83
|
+
`|\\bsuccessfully\\s+${HC_VERB}\\b` + // "successfully applied..."
|
|
84
|
+
`|\\b${HC_VERB}\\b[^.!?\\n]{0,20}\\bsuccessfully\\b` + // "...applied this successfully"
|
|
85
|
+
`|\\b(?:has|have)\\s+been\\s+${HC_VERB}\\b` + // "...has been applied"
|
|
86
|
+
`|\\bi\\s+(?:${HC_ADVERB}\\s+)?(?:updated|edited|fixed|changed|added|created|modified|implemented|removed|replaced|refactored|rewrote|wrote)\\s+(?:the|your|a|an|it|this|that|all|each|every)\\b`, // "I updated the header"
|
|
87
|
+
'i',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// The model writing a tool result itself instead of waiting for the program's
|
|
91
|
+
// real one. Results only ever arrive as a separate message, so this text in
|
|
92
|
+
// the model's own reply is always invented.
|
|
93
|
+
const FABRICATED_RESULT = /\[tool result\b|^\s*(?:exit code|exited with code)\s*[:=]?\s*\d+\s*$/im;
|
|
94
|
+
|
|
95
|
+
const FABRICATED_RESULT_CORRECTION =
|
|
96
|
+
'[system] Your last reply contains a tool result you wrote yourself. Tool results only come from the program, in a ' +
|
|
97
|
+
'separate message after you write an action block, and you have not received one for that. Nothing you described there ' +
|
|
98
|
+
'actually ran. Write the real action block and stop, then wait for its result.';
|
|
99
|
+
|
|
100
|
+
const HALLUCINATED_ACTION_CORRECTION =
|
|
101
|
+
"[system] Your last reply describes a file as already changed, but it contained no action block, and nothing has actually " +
|
|
102
|
+
"been written or edited yet this turn. Do not narrate work as done that you have not done - that leaves the user's " +
|
|
103
|
+
"request unfulfilled while the chat claims otherwise. Write the real <codeply:edit_file> or <codeply:write_file> action " +
|
|
104
|
+
"block now (search text copied verbatim from a file you have actually read), or if you genuinely cannot proceed, say so " +
|
|
105
|
+
"plainly instead of claiming success.";
|
|
106
|
+
|
|
107
|
+
// Filename-shaped tokens mentioned in the completion sentence(s), used to catch
|
|
108
|
+
// the narrower case: a turn where a REAL edit did happen, so the check above
|
|
109
|
+
// (gated on !madeAnyEdit) never fires, but the reply's completion claim lists
|
|
110
|
+
// more files than were actually touched this turn - "I've created index.html,
|
|
111
|
+
// styles.css, and app.js" after only index.html was actually write_file'd.
|
|
112
|
+
const FILENAME_TOKEN = /\b[\w-]+\.(?:html|htm|css|js|jsx|ts|tsx|mjs|cjs|py|json|md|yml|yaml|txt|svg|vue|svelte)\b/gi;
|
|
113
|
+
|
|
114
|
+
function extractClaimedFilenames(prose) {
|
|
115
|
+
const m = prose.match(FILENAME_TOKEN);
|
|
116
|
+
return m ? [...new Set(m.map((f) => f.toLowerCase()))] : [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const overclaimCorrection = (missing) =>
|
|
120
|
+
`[system] Your last reply claims ${missing.length === 1 ? 'this file was' : 'these files were'} changed, but ` +
|
|
121
|
+
`${missing.length === 1 ? 'it was' : 'they were'} never actually written or edited this turn: ${missing.join(', ')}. ` +
|
|
122
|
+
"Only claim what you actually did. Either write the real <codeply:write_file> or <codeply:edit_file> action block for " +
|
|
123
|
+
`${missing.length === 1 ? 'it' : 'each of them'} now, or correct your summary to describe only the file(s) you truly changed.`;
|
|
124
|
+
|
|
125
|
+
// Claims about CHECKING work ("I ran the tests", "tests pass", "I verified it
|
|
126
|
+
// in the browser") and about SHIPPING it ("I've deployed", "pushed to
|
|
127
|
+
// GitHub") are the other common confabulations. Each is only accepted if a
|
|
128
|
+
// tool that could actually have done it ran successfully this turn. First-
|
|
129
|
+
// person / result phrasing only, so advice like "you should run the tests" or
|
|
130
|
+
// "it's not deployed yet" never trips it.
|
|
131
|
+
const CLAIM_VERIFIED = new RegExp(
|
|
132
|
+
`\\bi(?:'ve|\\s+have)\\s+(?:${HC_ADVERB}\\s+|also\\s+|successfully\\s+){0,2}(?:run|ran|tested|verified|executed|confirmed|checked)\\b` +
|
|
133
|
+
`|\\ball\\s+(?:the\\s+)?tests\\s+(?:now\\s+)?pass` +
|
|
134
|
+
`|\\btests?\\s+(?:now\\s+)?(?:pass(?:ed|es)?|succeed(?:ed|s)?)\\b` +
|
|
135
|
+
`|\\bbuild\\s+(?:now\\s+)?(?:passes|succeeded|is\\s+successful)\\b` +
|
|
136
|
+
`|\\bverified\\s+(?:that\\s+)?(?:it|the\\s+(?:page|app|site|fix|change))\\s+works`,
|
|
137
|
+
'i',
|
|
138
|
+
);
|
|
139
|
+
const VERIFY_TOOLS = new Set(['run', 'browser_check']);
|
|
140
|
+
|
|
141
|
+
const CLAIM_SHIPPED = new RegExp(
|
|
142
|
+
`\\bi(?:'ve|\\s+have)\\s+(?:${HC_ADVERB}\\s+|also\\s+|successfully\\s+){0,2}(?:deployed|pushed|committed|published)\\b` +
|
|
143
|
+
`|\\bsuccessfully\\s+(?:deployed|pushed|committed|published)\\b` +
|
|
144
|
+
`|\\b(?:is|are)\\s+now\\s+live\\s+(?:at|on)\\b`,
|
|
145
|
+
'i',
|
|
146
|
+
);
|
|
147
|
+
const SHIP_TOOLS = new Set(['run', 'vercel_deploy', 'vercel_api', 'github_create_repo', 'supabase_api']);
|
|
148
|
+
|
|
149
|
+
const unbackedClaimCorrection = (what) =>
|
|
150
|
+
`[system] Your last reply says you ${what}, but no tool that could have done that ran successfully this turn. ` +
|
|
151
|
+
'Never report an action or a result you did not actually get from a tool. Either do it now with a real action block ' +
|
|
152
|
+
'(for checks: <codeply:run> a test/build/syntax command, or <codeply:browser_check> the page), or rewrite your summary ' +
|
|
153
|
+
'so it only states what you truly did and says plainly what you did not verify.';
|
|
154
|
+
|
|
155
|
+
// A file-changing turn is not finished until it's been checked. When the model
|
|
156
|
+
// tries to wrap up after editing code or pages without running anything
|
|
157
|
+
// against them since the last edit, it gets sent back once to verify.
|
|
158
|
+
const VERIFIABLE_EXT = /\.(html?|css|scss|less|jsx?|tsx?|mjs|cjs|vue|svelte|py|go|rs|rb|php|java|kt|cs|c|cc|cpp|h|hpp|swift|json|ya?ml|toml|sql|sh)$/i;
|
|
159
|
+
const verifyBeforeDoneCorrection = (files) =>
|
|
160
|
+
`[system] Before you finish: you changed ${files.join(', ')} but haven't checked the result since your last edit. ` +
|
|
161
|
+
'Verify it now - run a syntax check, the relevant test/lint/build script, or start the app; for a page, browser_check it ' +
|
|
162
|
+
'and look at the screenshot. Fix anything that fails and check again. If there is genuinely nothing you can run for this ' +
|
|
163
|
+
'change, say so explicitly in your final summary instead of implying it was tested.';
|
|
164
|
+
|
|
165
|
+
const failedCommandCorrection = (cmd, code) =>
|
|
166
|
+
`[system] The last command you ran (\`${cmd}\`) exited with code ${code}, so it failed. Do not describe the work as ` +
|
|
167
|
+
'working or complete while that is unresolved. Read the error output, fix the cause and run it again - or, if the ' +
|
|
168
|
+
'failure is unrelated to this task or can\'t be fixed here, say that plainly in your summary.';
|
|
169
|
+
|
|
170
|
+
// In Build mode, pasting a large code block into chat instead of writing it to
|
|
171
|
+
// a file usually means the user's requested change never happened.
|
|
172
|
+
const CODE_INSTEAD_OF_EDIT_CORRECTION =
|
|
173
|
+
'[system] You put a large code block in your reply but did not write any file this turn. If the user asked for a ' +
|
|
174
|
+
'change to their project, apply it with write_file or edit_file instead of pasting code into chat. If they only ' +
|
|
175
|
+
'wanted to see code (an explanation or a snippet to copy), you may finish as is.';
|
|
176
|
+
|
|
177
|
+
const MAX_HALLUCINATION_RETRIES = 3;
|
|
178
|
+
const MAX_VERIFY_NUDGES = 2;
|
|
179
|
+
|
|
180
|
+
// Look-around blocks that may share one reply (see the loop).
|
|
181
|
+
const BATCHABLE_TOOLS = new Set(['read_file', 'list_dir', 'search', 'web_fetch', 'web_search', 'lsp']);
|
|
182
|
+
const MAX_BATCH = 4;
|
|
183
|
+
|
|
184
|
+
// Identical call, identical arguments, this many times in a row = stuck.
|
|
185
|
+
const STUCK_REPEATS = 3;
|
|
186
|
+
|
|
187
|
+
function stuckCorrection(name, last) {
|
|
188
|
+
const failed = last && !last.ok;
|
|
189
|
+
const head = `[system] That is the same ${name} call ${STUCK_REPEATS} times in a row${failed ? ', and it failed every time' : ''}. ` +
|
|
190
|
+
'It was not run again, because the same input gives the same result. Change your approach instead of repeating it:';
|
|
191
|
+
const ways = {
|
|
192
|
+
run: [
|
|
193
|
+
'Read the error output of the last attempt word by word and fix what it actually names.',
|
|
194
|
+
'If a command or package is missing, check with `where <cmd>` (Windows) or `command -v <cmd>`, then install it or use one that exists.',
|
|
195
|
+
'Try a different way to reach the same goal: another tool, a script file instead of a long one-liner, or a smaller step first.',
|
|
196
|
+
'Check you are in the right folder and the paths in the command exist (list_dir).',
|
|
197
|
+
],
|
|
198
|
+
edit_file: [
|
|
199
|
+
'read_file the exact region you are editing and copy the search text from that result.',
|
|
200
|
+
'Make the search block smaller: 2-5 distinctive lines are enough.',
|
|
201
|
+
'If the change is large, write_file the whole file instead.',
|
|
202
|
+
],
|
|
203
|
+
}[name] || [
|
|
204
|
+
'Use what that call already returned, or try a different tool or different arguments.',
|
|
205
|
+
'If you are blocked, say plainly what is blocking you instead of retrying.',
|
|
206
|
+
];
|
|
207
|
+
return `${head}\n${ways.map((w) => `- ${w}`).join('\n')}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A reply with no action block that ends by announcing the next action ("Let
|
|
211
|
+
// me now update the header.") is not a final answer: the model meant to act
|
|
212
|
+
// and forgot the block. Seen from Hermes Agent's stall nudges (MIT, Nous
|
|
213
|
+
// Research). Questions to the user are excluded; those are real stops.
|
|
214
|
+
const ANNOUNCED_INTENT = /(?:^|[.!\n]\s*)(?:(?:ok(?:ay)?|now|next|so|great|alright|first|then),?\s+)*(?:let me|let's|i'll|i will|i'm going to|i am going to|i need to|now i(?:'ll| will)?)\s+(?:now\s+|also\s+|first\s+|go ahead and\s+)?(?:check|read|look|open|update|edit|fix|add|create|write|run|test|verify|search|find|install|implement|change|remove|apply|make|build|start|try|inspect|review|examine)\b[^?]{0,200}[.:!]?\s*$/i;
|
|
215
|
+
const STALL_CORRECTION =
|
|
216
|
+
'[system] Your reply announces what you are about to do but has no action block, so nothing ran and the task is ' +
|
|
217
|
+
'not done. Write that action block now. If you are actually finished, give the final summary instead; if you ' +
|
|
218
|
+
'need the user, ask them a direct question.';
|
|
219
|
+
const MAX_STALL_NUDGES = 2;
|
|
220
|
+
|
|
221
|
+
// Same tool failing again and again with DIFFERENT arguments is the other
|
|
222
|
+
// kind of stuck (the identical-call guard above doesn't see it). Warn, with
|
|
223
|
+
// ways around it, instead of letting it spiral. Threshold idea from Hermes
|
|
224
|
+
// Agent's tool_guardrails (MIT, Nous Research).
|
|
225
|
+
const FAILURE_STREAK_WARN = 3;
|
|
226
|
+
const failureStreakNote = (name, n) =>
|
|
227
|
+
`[system] ${name} has failed ${n} times in a row this turn. This looks like a loop. Stop and diagnose before the ` +
|
|
228
|
+
'next attempt: what exactly did the last error say, and what assumption does it break? ' +
|
|
229
|
+
(name === 'run'
|
|
230
|
+
? 'Check where you are and what exists (list_dir, or `cd` / `dir` / `ls`), try an absolute path, a simpler command, or a different tool for the same goal.'
|
|
231
|
+
: name === 'edit_file'
|
|
232
|
+
? 'read_file the exact region first, or write_file the whole file if the edit is large.'
|
|
233
|
+
: 'Try a different tool or a genuinely different approach.') +
|
|
234
|
+
' If nothing works, say plainly what is blocking you.';
|
|
235
|
+
|
|
236
|
+
// A,B,A,B (or A,B,C,A,B,C) with nothing changing in between: going in circles.
|
|
237
|
+
function findCycle(keys) {
|
|
238
|
+
for (const period of [2, 3]) {
|
|
239
|
+
if (keys.length < period * 2) continue;
|
|
240
|
+
const tail = keys.slice(-period * 2);
|
|
241
|
+
const a = tail.slice(0, period), b = tail.slice(period);
|
|
242
|
+
if (a.every((k, i) => k === b[i]) && new Set(a).size > 1) return period;
|
|
243
|
+
}
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
const cycleNote = (period) =>
|
|
247
|
+
`[system] Your last ${period * 2} actions repeat the same ${period}-step pattern with the same inputs, and none of ` +
|
|
248
|
+
'them changed anything. You are going in circles. Use what those results already told you and do something different.';
|
|
249
|
+
|
|
250
|
+
// Late in the step budget, one checkpoint so a long task wraps up properly
|
|
251
|
+
// instead of being cut off mid-edit. Idea from Hermes Agent / opencode.
|
|
252
|
+
const budgetCheckpointNote = (used, max) =>
|
|
253
|
+
`[system] You have used ${used} of ${max} steps for this turn. Finish the change you are in the middle of, check it, ` +
|
|
254
|
+
'and wrap up with your summary. If the whole task cannot fit, get the current part to a working state and list what is left.';
|
|
255
|
+
|
|
256
|
+
const FINAL_SUMMARY_PROMPT =
|
|
257
|
+
'[system] The step budget for this turn is used up, so actions are switched off: do not write any action block, it ' +
|
|
258
|
+
'would be ignored. Reply with a short summary for the user: what you actually changed (only files a tool result ' +
|
|
259
|
+
'confirms), what you checked and the result, and what is left to do. Do not claim anything the tool results do not show.';
|
|
260
|
+
|
|
46
261
|
// ─── Protocol ───────────────────────────────────────────────────────────────
|
|
47
262
|
|
|
48
263
|
const TOOL_TAG = /<codeply:([a-z_]+)>([\s\S]*?)<\/codeply:\1>/g;
|
|
49
264
|
|
|
50
265
|
// Params whose value is a raw payload (file content, code blocks) and may
|
|
51
266
|
// legitimately contain angle-bracket tags of its own.
|
|
52
|
-
const CONTAINER_PARAMS = new Set(['content', 'search', 'replace']);
|
|
267
|
+
const CONTAINER_PARAMS = new Set(['content', 'search', 'replace', 'body', 'text', 'patch']);
|
|
53
268
|
|
|
54
269
|
/** Tag payloads are written on their own lines; drop only that framing. */
|
|
55
270
|
function trimFraming(value) {
|
|
@@ -61,7 +276,7 @@ function trimFraming(value) {
|
|
|
61
276
|
*
|
|
62
277
|
* Container params are read first-open to last-close, so writing an HTML file
|
|
63
278
|
* that itself contains `</content>` does not truncate the payload. Their spans
|
|
64
|
-
* are then masked out before the scalar params are matched
|
|
279
|
+
* are then masked out before the scalar params are matched - otherwise a
|
|
65
280
|
* `<path>` appearing inside that same HTML payload would be picked up as the
|
|
66
281
|
* tool's path. (That is not hypothetical; it is what the test caught.)
|
|
67
282
|
*/
|
|
@@ -92,14 +307,37 @@ function extractParams(block, spec) {
|
|
|
92
307
|
}
|
|
93
308
|
|
|
94
309
|
const PARAMS = {
|
|
310
|
+
todo: ['items'],
|
|
311
|
+
ask_user: ['question', 'options'],
|
|
312
|
+
mcp: ['server', 'tool', 'args'],
|
|
95
313
|
list_dir: ['path'],
|
|
96
314
|
read_file: ['path', 'offset', 'limit'],
|
|
97
315
|
write_file: ['path', 'content'],
|
|
98
|
-
edit_file: ['path', 'search', 'replace'],
|
|
99
|
-
search: ['pattern', 'glob', 'path'],
|
|
316
|
+
edit_file: ['path', 'search', 'replace', 'all'],
|
|
317
|
+
search: ['pattern', 'glob', 'path', 'context', 'files_only'],
|
|
100
318
|
run: ['command'],
|
|
101
319
|
use_skill: ['name'],
|
|
102
320
|
list_skills: ['query'],
|
|
321
|
+
fetch_image: ['url', 'path'],
|
|
322
|
+
browser_check: ['url', 'wait', 'viewport'],
|
|
323
|
+
gmail_send: ['to', 'subject', 'body'],
|
|
324
|
+
gmail_search: ['query'],
|
|
325
|
+
slack_post_message: ['channel', 'text'],
|
|
326
|
+
vercel_deploy: ['path'],
|
|
327
|
+
supabase_create_project: ['name'],
|
|
328
|
+
supabase_delete_project: ['name', 'ref'],
|
|
329
|
+
github_create_repo: ['path', 'name'],
|
|
330
|
+
design_reference_search: ['term', 'category'],
|
|
331
|
+
view_images: ['urls'],
|
|
332
|
+
supabase_api: ['method', 'path', 'body'],
|
|
333
|
+
supabase_sql: ['ref', 'query'],
|
|
334
|
+
vercel_api: ['method', 'path', 'body'],
|
|
335
|
+
web_fetch: ['url', 'format'],
|
|
336
|
+
web_search: ['query', 'num'],
|
|
337
|
+
apply_patch: ['patch'],
|
|
338
|
+
plan_exit: ['path'],
|
|
339
|
+
plan_enter: ['reason'],
|
|
340
|
+
lsp: ['operation', 'path', 'line', 'character', 'symbol', 'query'],
|
|
103
341
|
};
|
|
104
342
|
|
|
105
343
|
// Names the model actually reaches for when it paraphrases the format.
|
|
@@ -107,6 +345,8 @@ const PARAMS = {
|
|
|
107
345
|
// those are parameter names, and treating them as action names would misread
|
|
108
346
|
// an edit_file block as a search.
|
|
109
347
|
const NAME_ALIASES = {
|
|
348
|
+
ask_user: 'ask_user', askuser: 'ask_user', ask: 'ask_user', question: 'ask_user', askquestion: 'ask_user',
|
|
349
|
+
todo: 'todo', todos: 'todo', todowrite: 'todo', todo_write: 'todo', tasklist: 'todo', task_list: 'todo',
|
|
110
350
|
read_file: 'read_file', readfile: 'read_file', read: 'read_file',
|
|
111
351
|
open: 'read_file', cat: 'read_file', view: 'read_file', openfile: 'read_file',
|
|
112
352
|
list_dir: 'list_dir', listdir: 'list_dir', list: 'list_dir',
|
|
@@ -118,6 +358,35 @@ const NAME_ALIASES = {
|
|
|
118
358
|
run: 'run', bash: 'run', shell: 'run', exec: 'run', runcommand: 'run',
|
|
119
359
|
use_skill: 'use_skill', useskill: 'use_skill', skill: 'use_skill', loadskill: 'use_skill',
|
|
120
360
|
list_skills: 'list_skills', listskills: 'list_skills', skills: 'list_skills', findskill: 'list_skills',
|
|
361
|
+
fetch_image: 'fetch_image', fetchimage: 'fetch_image', downloadimage: 'fetch_image',
|
|
362
|
+
download_image: 'fetch_image', getimage: 'fetch_image',
|
|
363
|
+
browser_check: 'browser_check', browsercheck: 'browser_check', checkbrowser: 'browser_check',
|
|
364
|
+
previewcheck: 'browser_check', browserpreview: 'browser_check', check: 'browser_check',
|
|
365
|
+
gmail_send: 'gmail_send', gmailsend: 'gmail_send', sendemail: 'gmail_send', email: 'gmail_send', sendmail: 'gmail_send',
|
|
366
|
+
gmail_search: 'gmail_search', gmailsearch: 'gmail_search', searchemail: 'gmail_search', searchgmail: 'gmail_search',
|
|
367
|
+
slack_post_message: 'slack_post_message', slackpostmessage: 'slack_post_message',
|
|
368
|
+
slackmessage: 'slack_post_message', slackpost: 'slack_post_message', postmessage: 'slack_post_message',
|
|
369
|
+
vercel_deploy: 'vercel_deploy', vercel: 'vercel_deploy', deploy: 'vercel_deploy', publish: 'vercel_deploy', vercelpublish: 'vercel_deploy',
|
|
370
|
+
supabase_create_project: 'supabase_create_project', supabase: 'supabase_create_project', createproject: 'supabase_create_project',
|
|
371
|
+
createdatabase: 'supabase_create_project', makedatabase: 'supabase_create_project', create_database: 'supabase_create_project',
|
|
372
|
+
supabase_delete_project: 'supabase_delete_project', deletesupabaseproject: 'supabase_delete_project',
|
|
373
|
+
deletedatabase: 'supabase_delete_project', removedatabase: 'supabase_delete_project', delete_database: 'supabase_delete_project',
|
|
374
|
+
github_create_repo: 'github_create_repo', github: 'github_create_repo', creategithubrepo: 'github_create_repo',
|
|
375
|
+
createrepo: 'github_create_repo', pushtogithub: 'github_create_repo',
|
|
376
|
+
design_reference_search: 'design_reference_search', designreferencesearch: 'design_reference_search',
|
|
377
|
+
designreference: 'design_reference_search', referencesearch: 'design_reference_search',
|
|
378
|
+
designlibrary: 'design_reference_search', searchdesignlibrary: 'design_reference_search',
|
|
379
|
+
view_images: 'view_images', viewimages: 'view_images', viewimage: 'view_images',
|
|
380
|
+
lookatimages: 'view_images', lookatimage: 'view_images', seeimages: 'view_images',
|
|
381
|
+
supabase_api: 'supabase_api', supabaseapi: 'supabase_api',
|
|
382
|
+
supabase_sql: 'supabase_sql', supabasesql: 'supabase_sql', sql: 'supabase_sql', runsql: 'supabase_sql',
|
|
383
|
+
vercel_api: 'vercel_api', vercelapi: 'vercel_api',
|
|
384
|
+
web_fetch: 'web_fetch', webfetch: 'web_fetch', fetchurl: 'web_fetch', fetch_url: 'web_fetch', fetchpage: 'web_fetch', openurl: 'web_fetch', readurl: 'web_fetch',
|
|
385
|
+
web_search: 'web_search', websearch: 'web_search', searchweb: 'web_search', search_web: 'web_search', googlesearch: 'web_search',
|
|
386
|
+
apply_patch: 'apply_patch', applypatch: 'apply_patch', patch: 'apply_patch',
|
|
387
|
+
plan_exit: 'plan_exit', planexit: 'plan_exit', exitplan: 'plan_exit', exit_plan: 'plan_exit',
|
|
388
|
+
plan_enter: 'plan_enter', planenter: 'plan_enter', enterplan: 'plan_enter', enter_plan: 'plan_enter',
|
|
389
|
+
lsp: 'lsp', codenav: 'lsp', gotodefinition: 'lsp', findreferences: 'lsp', go_to_definition: 'lsp', find_references: 'lsp',
|
|
121
390
|
};
|
|
122
391
|
|
|
123
392
|
// Any tag whose name resolves to an action, with or without the codeply: prefix.
|
|
@@ -126,8 +395,17 @@ const LOOSE_NAME_TAG = new RegExp(
|
|
|
126
395
|
);
|
|
127
396
|
|
|
128
397
|
// Does this reply look like it was *trying* to act, even though nothing parsed?
|
|
398
|
+
// The `<\|?` (not just `<\/?`) matters: some models - Gemma via OpenRouter in
|
|
399
|
+
// particular - reach for their own native tool-call channel and it leaks out
|
|
400
|
+
// as literal Harmony-style text, "<|tool_call>call:codeply:edit_file", instead
|
|
401
|
+
// of the "<codeply:edit_file>" tag this protocol needs. Without the `\|`
|
|
402
|
+
// alternative that text doesn't match, so it falls through as a "reply is
|
|
403
|
+
// finished" and gets shown to the user verbatim as raw protocol debris (or
|
|
404
|
+
// worse, ends up as literal content inside a write_file/edit_file the model
|
|
405
|
+
// attempts right after). Catching it here routes it into the normal malformed
|
|
406
|
+
// retry instead, which corrects the model back onto the real tag format.
|
|
129
407
|
const LOOKS_LIKE_ATTEMPT =
|
|
130
|
-
|
|
408
|
+
/<\|?\/?\s*(?:codeply[:_-])?(?:tool_?call|action|function_?call|invoke)\b|<(?:path|command|pattern|content|name|term)>/i;
|
|
131
409
|
|
|
132
410
|
/**
|
|
133
411
|
* A reply that opens an action block but never closes it almost always means
|
|
@@ -137,7 +415,7 @@ const LOOKS_LIKE_ATTEMPT =
|
|
|
137
415
|
* correction tells the model its *syntax* was wrong, which it wasn't, and
|
|
138
416
|
* the model then apologizes for a mistake it didn't make and repeats the
|
|
139
417
|
* same oversized write. Detecting this case lets the correction instead say
|
|
140
|
-
* "you ran out of room, write less at once"
|
|
418
|
+
* "you ran out of room, write less at once" - the thing that actually fixes it.
|
|
141
419
|
*/
|
|
142
420
|
function looksTruncated(text) {
|
|
143
421
|
return /<codeply:([a-z_]+)>(?![\s\S]*<\/codeply:\1>)/i.test(text);
|
|
@@ -146,7 +424,7 @@ function looksTruncated(text) {
|
|
|
146
424
|
/**
|
|
147
425
|
* Best-effort recovery when the strict protocol did not match.
|
|
148
426
|
*
|
|
149
|
-
* gpt-oss-120b paraphrases the format
|
|
427
|
+
* gpt-oss-120b paraphrases the format - inventing a <toolcall> wrapper,
|
|
150
428
|
* dropping the codeply: prefix, writing "readfile" for "read_file", even
|
|
151
429
|
* dropping a bracket. Rejecting those wastes the user's turn, so the intent is
|
|
152
430
|
* reconstructed from whatever survived. Checks run most-specific first: the
|
|
@@ -159,14 +437,25 @@ function recoverCall(text) {
|
|
|
159
437
|
let name = null;
|
|
160
438
|
if (has('content')) name = 'write_file';
|
|
161
439
|
else if (has('search') && has('replace')) name = 'edit_file';
|
|
440
|
+
else if (has('items')) name = 'todo';
|
|
441
|
+
else if (has('question') && has('options')) name = 'ask_user';
|
|
442
|
+
else if (has('server') && has('tool')) name = 'mcp';
|
|
443
|
+
else if (has('to') && has('subject')) name = 'gmail_send';
|
|
444
|
+
else if (has('channel') && has('text')) name = 'slack_post_message';
|
|
162
445
|
else {
|
|
163
446
|
const m = text.match(LOOSE_NAME_TAG);
|
|
164
447
|
if (m) name = NAME_ALIASES[m[1].toLowerCase().replace(/[_-]/g, '')] ?? NAME_ALIASES[m[1].toLowerCase()];
|
|
165
448
|
if (!name && has('command')) name = 'run';
|
|
166
449
|
else if (!name && has('pattern')) name = 'search';
|
|
450
|
+
// fetch_image and browser_check both take <url> now - <wait> only appears on browser_check.
|
|
451
|
+
else if (!name && has('url') && has('wait')) name = 'browser_check';
|
|
452
|
+
else if (!name && has('urls')) name = 'view_images'; // <urls> (plural) only appears on view_images
|
|
453
|
+
else if (!name && has('url')) name = 'fetch_image';
|
|
454
|
+
else if (!name && has('ref') && has('query')) name = 'supabase_sql';
|
|
167
455
|
else if (!name && has('path')) name = 'read_file';
|
|
168
456
|
else if (!name && has('name')) name = 'use_skill'; // only use_skill's params include <name>
|
|
169
457
|
else if (!name && has('query')) name = 'list_skills'; // only list_skills' params include <query>
|
|
458
|
+
else if (!name && has('term')) name = 'design_reference_search';
|
|
170
459
|
}
|
|
171
460
|
if (!name || !PARAMS[name]) return null;
|
|
172
461
|
|
|
@@ -175,6 +464,46 @@ function recoverCall(text) {
|
|
|
175
464
|
return { name, args, recovered: true };
|
|
176
465
|
}
|
|
177
466
|
|
|
467
|
+
/**
|
|
468
|
+
* poolside/laguna's own native tool-calling convention leaks through as
|
|
469
|
+
* literal text here - same root cause as the Harmony `<|tool_call>` leak
|
|
470
|
+
* above (a different model reaching for its own trained tool-call format
|
|
471
|
+
* because the proxy declares no tools), just a different shape:
|
|
472
|
+
* <tool_call>read_file<arg_key>path</arg_key><arg_value>drop.html</arg_value></tool_call>
|
|
473
|
+
* The previous approach - reject it and ask for the real tags via
|
|
474
|
+
* FORMAT_CORRECTION - turned out not to work on this model: it kept writing
|
|
475
|
+
* this exact shape through all 3 retries and the whole turn errored out with
|
|
476
|
+
* nothing done. Since the shape is completely consistent, parsing it
|
|
477
|
+
* directly as a first-class input is far more reliable than hoping a
|
|
478
|
+
* deeply-trained habit reforms on request.
|
|
479
|
+
*/
|
|
480
|
+
const POOLSIDE_CALL_RE = /<tool_call>\s*([a-zA-Z_]+)\s*([\s\S]*?)<\/tool_call>/gi;
|
|
481
|
+
const POOLSIDE_ARG_RE = /<arg_key>([\s\S]*?)<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/gi;
|
|
482
|
+
|
|
483
|
+
function parsePoolsideCalls(text) {
|
|
484
|
+
const calls = [];
|
|
485
|
+
let prose = '';
|
|
486
|
+
let lastIndex = 0;
|
|
487
|
+
POOLSIDE_CALL_RE.lastIndex = 0;
|
|
488
|
+
let m;
|
|
489
|
+
while ((m = POOLSIDE_CALL_RE.exec(text)) !== null) {
|
|
490
|
+
prose += text.slice(lastIndex, m.index);
|
|
491
|
+
lastIndex = m.index + m[0].length;
|
|
492
|
+
const rawName = m[1].toLowerCase();
|
|
493
|
+
const name = NAME_ALIASES[rawName] ?? (PARAMS[rawName] ? rawName : null);
|
|
494
|
+
if (!name) continue; // an unrecognized name in this shape isn't worth guessing at
|
|
495
|
+
const args = {};
|
|
496
|
+
POOLSIDE_ARG_RE.lastIndex = 0;
|
|
497
|
+
let am;
|
|
498
|
+
while ((am = POOLSIDE_ARG_RE.exec(m[2])) !== null) {
|
|
499
|
+
args[am[1].trim()] = am[2].trim();
|
|
500
|
+
}
|
|
501
|
+
calls.push({ name, args });
|
|
502
|
+
}
|
|
503
|
+
prose += text.slice(lastIndex);
|
|
504
|
+
return { prose, calls };
|
|
505
|
+
}
|
|
506
|
+
|
|
178
507
|
/**
|
|
179
508
|
* Split a model reply into prose and the actions embedded in it.
|
|
180
509
|
* @returns {{prose:string, calls:Array, recovered:boolean, malformed:boolean}}
|
|
@@ -191,23 +520,35 @@ export function parseReply(text) {
|
|
|
191
520
|
const name = m[1];
|
|
192
521
|
const block = m[2];
|
|
193
522
|
const spec = PARAMS[name];
|
|
194
|
-
|
|
523
|
+
// `end` is where this block closes in the reply, so the loop can cut the
|
|
524
|
+
// stored reply right after the last block it actually runs.
|
|
525
|
+
const end = m.index + m[0].length;
|
|
526
|
+
if (!spec) { calls.push({ name, args: {}, unknown: true, end }); continue; }
|
|
195
527
|
const args = extractParams(block, spec);
|
|
196
528
|
// Single-param actions tolerate a bare body: <codeply:read_file>x.js</...>
|
|
197
529
|
if (spec.length && Object.keys(args).length === 0 && block.trim()) {
|
|
198
530
|
args[spec[0]] = block.trim();
|
|
199
531
|
}
|
|
200
|
-
calls.push({ name, args });
|
|
532
|
+
calls.push({ name, args, end });
|
|
201
533
|
}
|
|
202
|
-
prose += text.slice(lastIndex);
|
|
203
534
|
|
|
204
535
|
if (calls.length > 0) {
|
|
205
|
-
|
|
536
|
+
// Whatever follows the last block was written before any result came
|
|
537
|
+
// back: at best a restated plan, at worst an invented outcome ("done, the
|
|
538
|
+
// tests pass"). It is never shown and never kept.
|
|
539
|
+
const trailing = text.slice(lastIndex).trim();
|
|
540
|
+
return { prose: prose.trim(), calls, recovered: false, malformed: false, truncated: false, trailing };
|
|
541
|
+
}
|
|
542
|
+
prose += text.slice(lastIndex);
|
|
543
|
+
|
|
544
|
+
const poolside = parsePoolsideCalls(text);
|
|
545
|
+
if (poolside.calls.length > 0) {
|
|
546
|
+
return { prose: poolside.prose.trim(), calls: poolside.calls, recovered: false, malformed: false, truncated: false };
|
|
206
547
|
}
|
|
207
548
|
|
|
208
549
|
const truncated = looksTruncated(text);
|
|
209
550
|
|
|
210
|
-
// Don't let the salvage path guess at a call built from a truncated block
|
|
551
|
+
// Don't let the salvage path guess at a call built from a truncated block -
|
|
211
552
|
// it would be missing content/search/replace and either fail oddly or write
|
|
212
553
|
// a partial file. Truncation gets its own, more accurate correction instead.
|
|
213
554
|
if (!truncated) {
|
|
@@ -241,12 +582,83 @@ function gitBranch(cwd) {
|
|
|
241
582
|
} catch { return null; }
|
|
242
583
|
}
|
|
243
584
|
|
|
585
|
+
function gitOutput(cwd, cmd) {
|
|
586
|
+
try {
|
|
587
|
+
return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim();
|
|
588
|
+
} catch { return ''; }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Instruction files the user keeps for agents. The nearest project-level file
|
|
592
|
+
// wins (walking up from cwd, stopping at the git root), so a repo with both an
|
|
593
|
+
// AGENTS.md and a parent folder's AGENTS.md doesn't get both stacked in. The
|
|
594
|
+
// user-wide one in ~/.codeply is always added on top. Same lookup order idea
|
|
595
|
+
// as opencode's session/instruction.ts.
|
|
596
|
+
const INSTRUCTION_FILES = ['CODEPLY.md', 'AGENTS.md', 'CLAUDE.md'];
|
|
597
|
+
const MAX_INSTRUCTION_CHARS = 8000;
|
|
598
|
+
|
|
599
|
+
function readCapped(file) {
|
|
600
|
+
try {
|
|
601
|
+
const text = fs.readFileSync(file, 'utf8').trim();
|
|
602
|
+
if (!text) return '';
|
|
603
|
+
return text.length > MAX_INSTRUCTION_CHARS
|
|
604
|
+
? `${text.slice(0, MAX_INSTRUCTION_CHARS)}\n[… truncated, read ${file} for the rest]`
|
|
605
|
+
: text;
|
|
606
|
+
} catch { return ''; }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function findInstructionFiles(cwd) {
|
|
610
|
+
const found = [];
|
|
611
|
+
const root = gitOutput(cwd, 'git rev-parse --show-toplevel');
|
|
612
|
+
const stopAt = root ? path.resolve(root) : path.parse(cwd).root;
|
|
613
|
+
let dir = path.resolve(cwd);
|
|
614
|
+
outer: while (true) {
|
|
615
|
+
for (const name of INSTRUCTION_FILES) {
|
|
616
|
+
const file = path.join(dir, name);
|
|
617
|
+
if (fs.existsSync(file) && fs.statSync(file).isFile()) { found.push(file); break outer; }
|
|
618
|
+
}
|
|
619
|
+
if (dir === stopAt || path.dirname(dir) === dir) break;
|
|
620
|
+
dir = path.dirname(dir);
|
|
621
|
+
}
|
|
622
|
+
const globalFile = path.join(os.homedir(), '.codeply', 'AGENTS.md');
|
|
623
|
+
if (fs.existsSync(globalFile)) found.push(globalFile);
|
|
624
|
+
return found;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function instructionsSection(cwd) {
|
|
628
|
+
const blocks = findInstructionFiles(cwd)
|
|
629
|
+
.map((file) => ({ file, text: readCapped(file) }))
|
|
630
|
+
.filter((b) => b.text)
|
|
631
|
+
.map((b) => `Instructions from: ${b.file}\n${b.text}`);
|
|
632
|
+
for (const b of plugins.instructionBlocks(cwd)) blocks.push(`Instructions from plugin ${b.plugin}:\n${b.text}`);
|
|
633
|
+
if (!blocks.length) return '';
|
|
634
|
+
return `PROJECT INSTRUCTIONS\nThe user wrote these for agents working here. Follow them; they override your defaults.\n\n${blocks.join('\n\n')}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
244
637
|
/** A compact snapshot of the project, so the agent starts oriented. */
|
|
245
638
|
export function buildProjectContext(cwd) {
|
|
246
|
-
const lines = [`Working directory: ${cwd}`, `Platform: ${process.platform}`];
|
|
639
|
+
const lines = [`Working directory: ${cwd}`, `Platform: ${process.platform}`, `Date: ${new Date().toISOString().slice(0, 10)}`];
|
|
640
|
+
// What `run` really executes under, so the model writes commands for that
|
|
641
|
+
// shell instead of assuming bash.
|
|
642
|
+
lines.push(process.platform === 'win32'
|
|
643
|
+
? 'Shell for run: cmd.exe (chain with &&; no heredocs, no $(...); for PowerShell use powershell -NoProfile -Command "..."; for multi-line content use write_file)'
|
|
644
|
+
: `Shell for run: ${process.env.SHELL ? path.basename(process.env.SHELL) : 'sh'}`);
|
|
247
645
|
|
|
248
646
|
const branch = gitBranch(cwd);
|
|
249
|
-
if (branch)
|
|
647
|
+
if (branch) {
|
|
648
|
+
lines.push(`Git branch: ${branch}`);
|
|
649
|
+
// Uncommitted changes are usually what the user is in the middle of, and
|
|
650
|
+
// recent commits say what just happened - both cheap and often the fastest
|
|
651
|
+
// way to know where to look.
|
|
652
|
+
const status = gitOutput(cwd, 'git status --short').split('\n').filter(Boolean);
|
|
653
|
+
if (status.length) {
|
|
654
|
+
lines.push(`Uncommitted changes (${status.length}):`, ...status.slice(0, 25));
|
|
655
|
+
if (status.length > 25) lines.push(`… and ${status.length - 25} more`);
|
|
656
|
+
} else {
|
|
657
|
+
lines.push('Working tree clean.');
|
|
658
|
+
}
|
|
659
|
+
const log = gitOutput(cwd, 'git log --oneline -5');
|
|
660
|
+
if (log) lines.push('Recent commits:', log);
|
|
661
|
+
}
|
|
250
662
|
|
|
251
663
|
const pkgPath = path.join(cwd, 'package.json');
|
|
252
664
|
if (fs.existsSync(pkgPath)) {
|
|
@@ -274,7 +686,7 @@ export function buildProjectContext(cwd) {
|
|
|
274
686
|
|
|
275
687
|
// NOTE ON WORDING: this reference deliberately avoids the words "tool",
|
|
276
688
|
// "call" and "function". The proxy runs openai/gpt-oss-120b, whose harmony
|
|
277
|
-
// training has a dedicated channel for function calls
|
|
689
|
+
// training has a dedicated channel for function calls - phrasing this as tool
|
|
278
690
|
// calling made it reach for the *native* mechanism, and Groq then rejected the
|
|
279
691
|
// whole response with "Tool choice is none, but model called a tool" because
|
|
280
692
|
// the proxy declares no tools. Framing it as an output format keeps the model
|
|
@@ -288,6 +700,32 @@ performs the action, and writes the result back to you as your next message.
|
|
|
288
700
|
|
|
289
701
|
Never emit a structured/native function invocation. Write the tags as text.
|
|
290
702
|
|
|
703
|
+
<codeply:todo>
|
|
704
|
+
<items>
|
|
705
|
+
[x] read index.html and styles.css
|
|
706
|
+
[>] add the pricing section
|
|
707
|
+
[ ] check it on desktop and mobile
|
|
708
|
+
</items>
|
|
709
|
+
</codeply:todo>
|
|
710
|
+
|
|
711
|
+
(todo: your checklist for a task with 3+ steps. Send the WHOLE list every time,
|
|
712
|
+
one item per line: [ ] pending, [>] in progress (only one), [x] done, [-] dropped.
|
|
713
|
+
Mark an item [x] only after a tool result confirmed it, never because you meant
|
|
714
|
+
to do it. If something failed, mark it [-] and add a revised item.)
|
|
715
|
+
|
|
716
|
+
<codeply:ask_user>
|
|
717
|
+
<question>Which database should the app use?</question>
|
|
718
|
+
<options>
|
|
719
|
+
SQLite file (Recommended)
|
|
720
|
+
Supabase
|
|
721
|
+
Plain JSON file
|
|
722
|
+
</options>
|
|
723
|
+
</codeply:ask_user>
|
|
724
|
+
|
|
725
|
+
(ask_user: only for a real decision the user must make and you can't infer:
|
|
726
|
+
the user taps an option or types their own answer. Up to 5 short options,
|
|
727
|
+
recommended first. Never for things you can find out by reading the project.)
|
|
728
|
+
|
|
291
729
|
<codeply:list_dir>
|
|
292
730
|
<path>src</path>
|
|
293
731
|
</codeply:list_dir>
|
|
@@ -301,8 +739,14 @@ Never emit a structured/native function invocation. Write the tags as text.
|
|
|
301
739
|
<codeply:search>
|
|
302
740
|
<pattern>createServer</pattern>
|
|
303
741
|
<glob>**/*.js</glob>
|
|
742
|
+
<context>3</context>
|
|
304
743
|
</codeply:search>
|
|
305
744
|
|
|
745
|
+
(search options: <context> 0-5 shows that many lines around each hit, often
|
|
746
|
+
enough to edit from without a separate read_file. <files_only>true</files_only>
|
|
747
|
+
lists just the matching files and hit counts. Leave out <pattern> and give only
|
|
748
|
+
<glob>, like <glob>*.test.js</glob>, to find files by name.)
|
|
749
|
+
|
|
306
750
|
<codeply:write_file>
|
|
307
751
|
<path>index.html</path>
|
|
308
752
|
<content>
|
|
@@ -320,6 +764,10 @@ those lines with the change applied
|
|
|
320
764
|
</replace>
|
|
321
765
|
</codeply:edit_file>
|
|
322
766
|
|
|
767
|
+
(edit_file option: <all>true</all> replaces EVERY exact occurrence of the
|
|
768
|
+
search text, for a rename across one file. Without it the search text must
|
|
769
|
+
match exactly one place.)
|
|
770
|
+
|
|
323
771
|
<codeply:run>
|
|
324
772
|
<command>node --check index.js</command>
|
|
325
773
|
</codeply:run>
|
|
@@ -332,33 +780,195 @@ those lines with the change applied
|
|
|
332
780
|
<query>testing</query>
|
|
333
781
|
</codeply:list_skills>
|
|
334
782
|
|
|
783
|
+
<codeply:fetch_image>
|
|
784
|
+
<url>https://loremflickr.com/1600/900/tea,leaves</url>
|
|
785
|
+
<path>assets/hero.jpg</path>
|
|
786
|
+
</codeply:fetch_image>
|
|
787
|
+
|
|
788
|
+
<codeply:browser_check>
|
|
789
|
+
<url>file:///absolute/path/to/index.html</url>
|
|
790
|
+
<viewport>mobile</viewport>
|
|
791
|
+
</codeply:browser_check>
|
|
792
|
+
|
|
793
|
+
<codeply:gmail_send>
|
|
794
|
+
<to>someone@example.com</to>
|
|
795
|
+
<subject>Subject line</subject>
|
|
796
|
+
<body>
|
|
797
|
+
The email body, plain text.
|
|
798
|
+
</body>
|
|
799
|
+
</codeply:gmail_send>
|
|
800
|
+
|
|
801
|
+
<codeply:gmail_search>
|
|
802
|
+
<query>from:someone@example.com is:unread</query>
|
|
803
|
+
</codeply:gmail_search>
|
|
804
|
+
|
|
805
|
+
<codeply:slack_post_message>
|
|
806
|
+
<channel>general</channel>
|
|
807
|
+
<text>
|
|
808
|
+
The message to post.
|
|
809
|
+
</text>
|
|
810
|
+
</codeply:slack_post_message>
|
|
811
|
+
|
|
812
|
+
<codeply:vercel_deploy>
|
|
813
|
+
<path>.</path>
|
|
814
|
+
</codeply:vercel_deploy>
|
|
815
|
+
|
|
816
|
+
<codeply:supabase_create_project>
|
|
817
|
+
<name>my-project</name>
|
|
818
|
+
</codeply:supabase_create_project>
|
|
819
|
+
|
|
820
|
+
<codeply:supabase_delete_project>
|
|
821
|
+
<name>my-project</name>
|
|
822
|
+
</codeply:supabase_delete_project>
|
|
823
|
+
|
|
824
|
+
<codeply:github_create_repo>
|
|
825
|
+
<path>.</path>
|
|
826
|
+
<name>my-project</name>
|
|
827
|
+
</codeply:github_create_repo>
|
|
828
|
+
|
|
829
|
+
<codeply:supabase_api>
|
|
830
|
+
<method>GET</method>
|
|
831
|
+
<path>/v1/projects</path>
|
|
832
|
+
</codeply:supabase_api>
|
|
833
|
+
|
|
834
|
+
<codeply:supabase_sql>
|
|
835
|
+
<ref>abcdefghijklmnopqrst</ref>
|
|
836
|
+
<query>
|
|
837
|
+
create table if not exists todos (id bigint generated always as identity primary key, title text not null, done boolean default false);
|
|
838
|
+
</query>
|
|
839
|
+
</codeply:supabase_sql>
|
|
840
|
+
|
|
841
|
+
<codeply:vercel_api>
|
|
842
|
+
<method>GET</method>
|
|
843
|
+
<path>/v9/projects</path>
|
|
844
|
+
</codeply:vercel_api>
|
|
845
|
+
|
|
846
|
+
<codeply:design_reference_search>
|
|
847
|
+
<term>to-do list</term>
|
|
848
|
+
<category>productivity</category>
|
|
849
|
+
</codeply:design_reference_search>
|
|
850
|
+
|
|
851
|
+
<codeply:view_images>
|
|
852
|
+
<urls>https://example.com/screenshot1.jpg,https://example.com/screenshot2.jpg</urls>
|
|
853
|
+
</codeply:view_images>
|
|
854
|
+
|
|
855
|
+
<codeply:web_search>
|
|
856
|
+
<query>vite 6 breaking changes</query>
|
|
857
|
+
</codeply:web_search>
|
|
858
|
+
|
|
859
|
+
<codeply:web_fetch>
|
|
860
|
+
<url>https://vite.dev/guide/migration</url>
|
|
861
|
+
</codeply:web_fetch>
|
|
862
|
+
|
|
863
|
+
(web_search finds pages, web_fetch reads one as text. Use them for current docs,
|
|
864
|
+
error messages you do not recognise, or anything after your training. Search
|
|
865
|
+
first, then fetch the best result. Never invent a URL you did not get from a
|
|
866
|
+
search or the user.)
|
|
867
|
+
|
|
868
|
+
<codeply:lsp>
|
|
869
|
+
<operation>references</operation>
|
|
870
|
+
<path>src/api.ts</path>
|
|
871
|
+
<symbol>fetchUser</symbol>
|
|
872
|
+
</codeply:lsp>
|
|
873
|
+
|
|
874
|
+
(lsp: code navigation for TypeScript/JavaScript projects. operation is one of
|
|
875
|
+
definition, references, implementation, hover (type and docs), documentSymbol
|
|
876
|
+
(outline of a file), workspaceSymbol (give <query>). Point at a spot with <line>
|
|
877
|
+
(1-based) plus <symbol> (the name on that line) or <character>. Prefer it over
|
|
878
|
+
search when you need the real definition or every real use of a name, for
|
|
879
|
+
example before renaming or changing a function's signature.)
|
|
880
|
+
|
|
881
|
+
<codeply:apply_patch>
|
|
882
|
+
<patch>
|
|
883
|
+
*** Begin Patch
|
|
884
|
+
*** Update File: src/app.js
|
|
885
|
+
@@ function start()
|
|
886
|
+
const port = 3000;
|
|
887
|
+
-app.listen(port);
|
|
888
|
+
+app.listen(port, () => console.log('up'));
|
|
889
|
+
*** Add File: src/config.js
|
|
890
|
+
+export const port = 3000;
|
|
891
|
+
*** Delete File: old.js
|
|
892
|
+
*** End Patch
|
|
893
|
+
</patch>
|
|
894
|
+
</codeply:apply_patch>
|
|
895
|
+
|
|
896
|
+
(apply_patch: several file changes in one call, approved once. Every change line
|
|
897
|
+
starts with " " (context), "-" (remove) or "+" (add). "@@ text" anchors a hunk
|
|
898
|
+
to a line. Use it when one change touches 2+ files or many spots; use edit_file
|
|
899
|
+
for one spot. Copy context and removed lines exactly from a read.)
|
|
900
|
+
|
|
901
|
+
<codeply:plan_exit>
|
|
902
|
+
<path>.codeply/plans/add-dark-mode.md</path>
|
|
903
|
+
</codeply:plan_exit>
|
|
904
|
+
|
|
905
|
+
(plan_exit: Plan mode only. After writing the plan file, call this to ask the user
|
|
906
|
+
whether to switch to Build mode and carry it out.)
|
|
907
|
+
|
|
335
908
|
RULES
|
|
336
|
-
- Write
|
|
909
|
+
- Write ONE action block per reply, then stop and wait for its result. The one
|
|
910
|
+
exception is looking around: up to 4 read_file / list_dir / search blocks may
|
|
911
|
+
go in the same reply, and they all run before you get the results back
|
|
912
|
+
together. Anything that changes something (write_file, edit_file, run, ...)
|
|
913
|
+
is always alone in its reply.
|
|
914
|
+
- Stop writing the moment your action block closes. You have not seen its
|
|
915
|
+
result yet, so anything you write after it is a guess, and it is discarded
|
|
916
|
+
unread. Never write "[tool result" yourself; results only come from the program.
|
|
337
917
|
- Do not waste steps. Every action block costs the user several seconds, so never
|
|
338
918
|
perform one whose answer you already have:
|
|
339
|
-
· If the user gave a full path, read it directly
|
|
919
|
+
· If the user gave a full path, read it directly - do not list_dir first.
|
|
340
920
|
· After a successful edit_file or write_file, do NOT read the file back. The
|
|
341
921
|
result already confirms it applied. Only re-read a part you have not seen.
|
|
342
922
|
· Do not re-read a file that is already earlier in this conversation.
|
|
343
923
|
- When the result comes back, keep going on your own. Do not ask the user what to do next while you still have obvious work left.
|
|
344
|
-
-
|
|
924
|
+
- If an action fails, do not apologize or re-explain what you were trying to do - that wastes a whole reply and fixes nothing. Look at the actual error, and on your very next reply either fix the real cause or try something genuinely different. If the same action has now failed more than once for the same reason, that reason is not going to change on a third identical attempt - stop and re-read the file, or the error, or rethink the approach instead of repeating it.
|
|
925
|
+
- WORKAROUNDS: when something fails because of the tool or the environment rather than your code (the shell chokes on a heredoc or quotes, a command is missing, a command line is too long, a port is taken, a path is wrong), do not repeat the same approach with small tweaks. Switch to a different way to reach the same goal: write the file with write_file instead of echo/heredoc, put a long or multi-line command in a script file and run that, use another command that does the same job, or install what is missing. Say in one short sentence what you are switching to and why, then do it.
|
|
926
|
+
- read_file before you edit_file. "search" must be copied verbatim from what you just read - never from memory, never with the line-number prefixes the reader adds.
|
|
927
|
+
- LONG FILES: do not read a big file top to bottom to find one spot. search for a
|
|
928
|
+
distinctive name first (with <context>), then read_file just that region with
|
|
929
|
+
<offset>/<limit> (offset is the 0-based line to start at), then edit_file it.
|
|
930
|
+
If an edit_file fails, its error shows the closest matching lines with numbers:
|
|
931
|
+
copy from those instead of re-reading the whole file.
|
|
932
|
+
- Before an action block write at most one short sentence (or nothing). Don't narrate plans, apologize, or restate what you just did; the user sees every action anyway. Save the explanation for your final answer.
|
|
933
|
+
- Keep "search" as narrow as the change actually is. When removing something, search should span exactly the thing being removed - not "from here to the end of the file" just because that was easy to copy. A wider span deletes whatever sits between your intended target and wherever you stopped, silently, even when it renders fine and reports no errors. If two things need removing and something unrelated sits between them, that is two edit_file calls, not one wide one.
|
|
345
934
|
- edit_file for changes to an existing file. write_file only for new files or a genuine full rewrite.
|
|
346
935
|
- Prefer run for anything you can check mechanically.
|
|
347
|
-
-
|
|
348
|
-
-
|
|
349
|
-
-
|
|
936
|
+
- run executes a real shell command on the user's own machine, in their own project directory, with their own git/gh credentials already configured - the exact same terminal they'd get typing it themselves. That includes git add, git commit, git push, gh pr create, npm install, or anything else. Never tell the user you don't have terminal or network access, or that you can't run a command - if it's a shell command, write a <codeply:run> action block and run it for real. Do not just describe what the command would do.
|
|
937
|
+
- If a task needs a CLI that turns out not to be installed, install it yourself with the platform's own package manager (winget on Windows, brew on macOS, apt/apt-get on Linux) via run before falling back to a manual workaround - do not immediately hand the user a "go do this in a browser" set of steps just because a binary is missing; installing it is itself a shell command. The one thing you genuinely cannot do unattended is an interactive auth step a CLI requires after installing (e.g. gh auth login opening a browser for a device code) - if the install succeeds but the tool then reports it isn't authenticated, that specific login step is the only part to ask the user for, not the whole task.
|
|
938
|
+
- Deploying to Vercel, creating a Supabase project, or creating a new GitHub repo and pushing to it are NOT CLI tasks here - do not install or shell out to the vercel CLI, the supabase CLI, or gh repo create for these. Use vercel_deploy, supabase_create_project, and github_create_repo instead: they call the connected account directly (once the user has connected it from Connect Apps), with no separate CLI login step and no "go create an empty repo on github.com first." If one reports its integration isn't connected, say so plainly and point the user to Connect Apps (account menu) - do not fall back to the CLI/manual route as a workaround, and do not install the vercel/supabase CLI to route around a missing connection.
|
|
939
|
+
- FULL SUPABASE ACCESS: when Supabase is connected (see CONNECTED SERVICES), supabase_api reaches the entire Supabase Management API (https://api.supabase.com, paths start with /v1/) - list projects (GET /v1/projects), read API keys (GET /v1/projects/{ref}/api-keys?reveal=true), manage auth config, storage buckets, edge functions, secrets, branches, and more. supabase_sql runs SQL directly against a project's Postgres database - create and alter tables, write RLS policies, seed data, inspect schemas (select from information_schema). Always look up the real project ref first (GET /v1/projects) instead of guessing it. Read requests run immediately; changes ask the user first. Enable RLS and add policies on any table that holds user data.
|
|
940
|
+
- FULL VERCEL ACCESS: when Vercel is connected, vercel_api reaches the entire Vercel REST API (https://api.vercel.com, paths start with a version like /v9/) - projects (GET /v9/projects), deployments and their status/logs (GET /v6/deployments, GET /v13/deployments/{id}, GET /v3/deployments/{id}/events), env vars (/v10/projects/{id}/env), domains (/v10/projects/{id}/domains). The team id is added for you. Use vercel_deploy to ship a folder, then vercel_api to confirm the deployment reached READY and to read its build logs if it failed - do not report a deployment as live until you've seen it succeed.
|
|
941
|
+
- Tool results are the only source of truth about what happened. A failed result (non-2xx status, non-zero exit code, "did not apply") means it did not happen - never describe it as done.
|
|
942
|
+
- Creating a remote repo, pushing code, enabling Pages, or deploying anything are exactly the kind of claim covered by "never report success you did not verify" above - and the easiest one to get wrong, because each step's own command can silently no-op or partially fail while a LATER step still appears to succeed. Concretely: run whoami equivalents (gh api user, gh auth status) to get the real signed-in username BEFORE building any URL with it - never guess a username from the OS account name, the folder name, or anything the user said earlier that could be stale; after gh repo create or git push, treat the command's own exit code and printed output as the only source of truth for whether it worked, not your prior turn's summary of what you intended to do - a command you ran two turns ago having succeeded is not evidence this turn's retry did too; and never hand the user a repository/deployment URL you have not just confirmed resolves (curl -I it, or read it back from the command's own output) - a plausible-looking URL built from a guessed username/slug is a fabrication even if the pattern is usually right.
|
|
943
|
+
- gmail_send and slack_post_message send a real email or a real Slack message the moment they run - there is no draft state, no "preview" mode. Only use them when the user actually asked for that email/message to go out, never speculatively, never as a way to "show" them what it would say. If gmail_search or a prior message makes clear Gmail/Slack isn't connected, say so plainly and stop - do not retry hoping it connects itself, and do not claim you sent something when the tool reported it wasn't connected.
|
|
944
|
+
- vercel_deploy, supabase_create_project, supabase_delete_project, and github_create_repo are the same category as gmail_send/slack_post_message above: real, immediate action the moment they run - a live production deployment, a newly provisioned cloud database with its own bill, a brand-new repository pushed with the user's code. Only use them when the user actually asked for that outcome, never speculatively "to check if it would work." If one reports its integration isn't connected, say so plainly and stop rather than retrying or working around it. supabase_delete_project is the sharpest of these - it permanently destroys a database with no undo - so only reach for it when the user has clearly asked to delete or remove a specific project, never as cleanup for something that merely looks unused.
|
|
945
|
+
- Every image in generated markup must be a local file, downloaded with fetch_image. NEVER write an <img> or CSS background-image pointing straight at loremflickr.com, picsum.photos, or any other live generator URL - those are redirect services that return a DIFFERENT random photo on every single request, so the page shows a different (sometimes completely unrelated) image on every reload, every redeploy, every visitor. Always fetch_image the URL to a real path under assets/ first, then reference that local path in the markup. If the user hasn't given you specific photos and the site needs placeholder imagery, fetch_image from https://loremflickr.com/<width>/<height>/<keyword1>,<keyword2> - it pulls a real tagged photo matching those keywords, no API key needed. Pick keywords that actually describe THAT section's subject (a tea shop's hero: 'tea,leaves' or 'matcha,ceremony', not generic filler) - never use a source that returns fully random, unrelated stock photos (e.g. picsum.photos) on a themed site; a beach or a crowd photo under a tea brand's "Our Heritage" section is worse than no image. If a downloaded placeholder turns out to be a broken/static-noise "no match" image or is visibly unrelated to its section once you look at the page, delete it and fetch_image again with more specific keywords - do not leave a wrong or corrupted image in place.
|
|
946
|
+
- If a SKILLS entry below is a clear match for the task, use_skill it before starting - its instructions take priority over your own default approach for that kind of work. Do not use_skill speculatively; only when a listed skill actually matches what you are about to do. A name under LIKELY RELEVANT TO THIS REQUEST, if that section is present, was matched against your actual request from the full library - treat it exactly the same way: use_skill it before starting unless it's obviously a false match, do not silently ignore it in favor of guessing your own approach.
|
|
947
|
+
- EXCEPTION - this one is not speculative: if the task is to build or restyle any page a human will look at in a browser (a landing page, a small-business site, a portfolio, a dashboard, any HTML/CSS/UI), use_skill 'premium-web-design' before writing markup, even if the brief sounds tiny or mundane ("a site for a tea shop"). A plain-sounding brief is not permission for a flat, default-Bootstrap-looking result - Codeply's bar is that every generated page reads as deliberately designed. Skip this only if the user explicitly asked for something minimal/utilitarian/no-frills.
|
|
948
|
+
- EXCEPTION - this one is MANDATORY, not speculative, and comes before you write any markup for that same kind of task (a page or app screen a human will look at): call design_reference_search on the core screen(s) the app needs (an onboarding flow, a checkout, a settings screen, a to-do list's main view - whatever the brief actually calls for) before designing it from memory. It is a local library (no external account, no login, no network dependency) so it is always available - do not skip this step, and do not substitute your own guess at what that kind of screen "usually" looks like. Real shipped apps solve layout/hierarchy/empty-state problems in ways worth matching, not just imitating the vibe of. Only if it reports the library itself is missing on this machine should you say that plainly to the user once and continue from your own judgment.
|
|
949
|
+
- CALLING design_reference_search IS NOT THE SAME AS USING IT. It returns a list of screenshot URLs - that is raw material, not a design brief, and the search result text alone tells you nothing about what those screens actually look like. fetch_image does NOT show you the image either - it only downloads it to disk, silently, no vision, so calling it on a reference screenshot accomplishes nothing here. Before you write markup, call view_images with 2-4 of the returned screenshot URLs (from different apps, not all from result #1) - that is the only tool that actually attaches the image for you to look at: the real layout structure, spacing, type scale, color choices, empty states, and component patterns those apps ship with. Build FROM specific things you saw in those images, not from a generic idea of what that kind of app "usually" looks like. If you searched but did not view_images any of the results, you did not reference real apps this turn - say that plainly in your summary ("I designed this from my own judgment, not a specific reference") rather than claiming you drew from real app patterns. This applies retroactively too: if the user later asks what reference you used, answer from what you actually did in this conversation (did you view_images any screenshots, and which apps), never from a guess or a vague assumption that you probably did - being wrong about your own prior actions is worse than admitting you skipped the step.
|
|
950
|
+
- design_reference_search searches a private library built ahead of time (914 real apps, 6,433 screenshots from official App Store listings, covering productivity/finance/shopping/social/travel/food_delivery/health_fitness/education/entertainment/real_estate). A category filter narrows results; term alone searches across all categories. Same fetch_image requirement applies - see the rule above.
|
|
951
|
+
- EXCEPTION - also not speculative, and it is the LAST thing you do before replying, not something you might get to: if you wrote or edited any HTML/CSS/JS/frontend file this turn, browser_check the actual page that changed (not just the file you touched - file:///<absolute path> for a static file, or http://localhost:<port> if the project needs a server, start one with run first if nothing is serving yet) BEFORE telling the user it's done. A page you have not opened is a page you do not know works. When it's available, the result includes an actual screenshot of the page as it just rendered, not only a text extraction - look at that image before judging the page correct; a layout that's visually broken, a section that's misaligned, or an image that rendered as a broken-icon placeholder won't always show up as a console error or missing text, so text-only reasoning is not enough. Read the report like a bug filed against you: an error names a file and often a line, and the screenshot shows you what a user would actually see - go fix whichever is wrong, then browser_check the same page again, and repeat until both the report and the screenshot come back clean. Do not call two checks "the same" or "different" from memory or assumption - judge each one from what that check actually returned. A fix in a shared file (a stylesheet, a component several pages import) can affect pages you did not start from - browser_check those too before you finish. Skip this only if browser_check reports itself unavailable (say so once, then continue from the source) or the task has no page to render (a CLI script, a backend-only route).
|
|
952
|
+
- Writing style: never use em dashes (the long dash character) in your replies. Use a comma, a period, or a plain hyphen instead.
|
|
953
|
+
- browser_check takes an optional <viewport>: desktop (default), tablet (768px), mobile (390px), or WIDTHxHEIGHT. After building or restyling a page, check it at desktop AND mobile. A NOT RESPONSIVE line means it scrolls sideways on phones: fix it (flexible widths, wrapping, media queries, a viewport meta tag) and check mobile again.
|
|
954
|
+
- If the user asks for a screenshot, or to see / show them a page, browser_check that page: the screenshot it takes is sent to the user in this chat automatically (on their PC and their phone). Say in one line that the screenshot is above; never claim a screenshot you didn't take.
|
|
955
|
+
- You are one agent and you do all of the work yourself, one step at a time. There is no one to delegate to and no background worker - never say you've handed something off.
|
|
956
|
+
- The SKILLS list below is a subset. If the task is a specific kind of specialized work (a particular framework, a particular deliverable type) that doesn't clearly match anything listed, try list_skills with a one- or two-word query before assuming there's no skill for it - there are 281 in total, not just the ones shown.
|
|
957
|
+
- STACK CHOICE - when the user asks you to build a new app/tool/site from scratch and does not say what to build it with (no "in React", "as an Electron app", "using Vite", etc.), do not silently default to a multi-file React project or an Electron shell - those add a build step, a node_modules install, and a packaging story the user did not ask for. Instead, ask one short question (with ask_user when it is available, otherwise as prose with no action block): whether they want a single self-contained HTML file (open it straight in a browser, nothing to install) or a specific framework/Electron/React setup instead, and briefly say why a single file is the lighter default. Wait for their answer before writing any code. Skip this ask only when the user already named a stack, is clearly extending/matching an existing project's stack (react/electron files already in the folder), or explicitly said to just decide for them.
|
|
958
|
+
- When the task is finished, reply with prose only and NO action block. That ends your turn. End with a short, plain-English close: what you actually changed (only files you really wrote or edited), how you verified it (only checks you really ran, and their result), and anything left undone or unverified. Never list a file, a check, or a result that doesn't appear in a tool result from this conversation.`;
|
|
350
959
|
|
|
351
960
|
const VERIFY_RULES = `VERIFYING YOUR WORK
|
|
352
961
|
You are not done when the code is written. You are done when it is checked.
|
|
353
962
|
- After editing JS/TS, run a syntax check (e.g. node --check <file>).
|
|
354
963
|
- If the project has a test/lint/build script, run the relevant one.
|
|
964
|
+
- After editing any HTML/CSS/frontend file, browser_check the page it changed. This is not optional when the capability is available - console errors, broken images, and a blank render are exactly as real a failure as a failing test, and reading source code does not catch them; only opening the page does. When the check returns a screenshot, actually look at it - that is the real verification, not the text report around it. Never guess or assume what the page looks like from having generated the markup; judge it from what you were just shown.
|
|
355
965
|
- If a command fails, read the error, fix the cause, and run it again. Repeat until it passes or you have a specific reason it cannot.
|
|
356
966
|
- Never report success for something you did not verify. If you could not verify it, say exactly that.`;
|
|
357
967
|
|
|
358
968
|
const MODE_PROMPT = {
|
|
359
969
|
Build: `You are Codeply, an autonomous coding agent working in the user's terminal.
|
|
360
970
|
|
|
361
|
-
Do the work the user asked for, end to end. Explore the project before assuming anything about it
|
|
971
|
+
Do the work the user asked for, end to end. Explore the project before assuming anything about it - read the actual files, do not guess at their contents or invent APIs. Make the change, then verify it.
|
|
362
972
|
|
|
363
973
|
${VERIFY_RULES}
|
|
364
974
|
|
|
@@ -373,9 +983,13 @@ Keep prose short. A sentence about what you are about to do, then the action blo
|
|
|
373
983
|
|
|
374
984
|
Plan: `You are Codeply, working in Plan mode in the user's terminal.
|
|
375
985
|
|
|
376
|
-
Investigate and produce a concrete plan. You MAY read, list, search, and run read-only commands to ground the plan in what is actually there. You MUST NOT
|
|
986
|
+
Investigate and produce a concrete plan. You MAY read, list, search, fetch web pages, and run read-only commands to ground the plan in what is actually there. You MUST NOT change the project: the only file you may write is your plan, under .codeply/plans/.
|
|
377
987
|
|
|
378
|
-
|
|
988
|
+
Workflow:
|
|
989
|
+
1. Explore first. If a real decision is the user's to make, ask_user before planning around a guess.
|
|
990
|
+
2. Write the plan to .codeply/plans/<short-slug>.md with write_file (a new file each time, a short kebab-case slug). Start with a one-line goal, then what you found (the specific files and functions involved), then numbered steps with concrete paths and names, then how it will be verified. Flag anything genuinely ambiguous instead of picking silently.
|
|
991
|
+
3. Call plan_exit with that path. The user is asked whether to switch to Build mode. If they say yes you become the builder and carry the plan out; if they say no, keep refining the plan with them.
|
|
992
|
+
4. Your final message summarises the plan in a few lines and names the plan file.`,
|
|
379
993
|
|
|
380
994
|
Ask: `You are Codeply, working in Ask mode in the user's terminal.
|
|
381
995
|
|
|
@@ -386,30 +1000,125 @@ Be concise and concrete. Quote the relevant code and cite it as path:line. If th
|
|
|
386
1000
|
|
|
387
1001
|
const READ_ONLY_MODES = new Set(['Plan', 'Ask']);
|
|
388
1002
|
|
|
1003
|
+
/** Files an apply_patch text adds, updates or moves to (for gates that run before the tool does). */
|
|
1004
|
+
function patchTargets(text) {
|
|
1005
|
+
return [...String(text || '').matchAll(/^\*\*\* (?:Add File|Update File|Move to):\s*(.+?)\s*$/gm)].map((m) => m[1]);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
389
1008
|
/**
|
|
390
1009
|
* Skills add capability without adding weight proportional to the whole
|
|
391
|
-
* library: only a curated subset's name + capped description goes in here
|
|
1010
|
+
* library: only a curated subset's name + capped description goes in here -
|
|
392
1011
|
* see lib/skills.js for why the bundled set (281 skills, ~3.4MB) can't be
|
|
393
1012
|
* injected in full on every request without the skill library itself becoming
|
|
394
1013
|
* the majority of the token cost. The rest is one list_skills query away.
|
|
395
1014
|
*/
|
|
396
|
-
function buildSkillIndex() {
|
|
397
|
-
const all = skills.listSkills();
|
|
1015
|
+
function buildSkillIndex(userMessage, cwd) {
|
|
1016
|
+
const all = skills.listSkills(cwd);
|
|
398
1017
|
const daily = skills.formatSkillIndex(all);
|
|
399
1018
|
if (!daily) return null;
|
|
400
1019
|
const rest = all.length - all.filter((s) => s.daily).length;
|
|
401
|
-
|
|
402
|
-
`when its description clearly matches the current task. This is a subset
|
|
1020
|
+
let out = `SKILLS\nOptional playbooks for specific kinds of work. Load one with use_skill ` +
|
|
1021
|
+
`when its description clearly matches the current task. This is a subset - ` +
|
|
403
1022
|
`${rest} more exist; search them with list_skills if nothing here fits:\n${daily}`;
|
|
1023
|
+
|
|
1024
|
+
// Computed fresh from the actual request, not a fixed list - this is what
|
|
1025
|
+
// lets one of the other ~244 skills get reached without the model having to
|
|
1026
|
+
// guess a good list_skills query first. Excludes daily skills: they're
|
|
1027
|
+
// already shown above, no need to say the same name twice.
|
|
1028
|
+
const relevant = skills.findRelevantSkills(userMessage, all, { limit: 6, excludeDaily: true });
|
|
1029
|
+
if (relevant.length) {
|
|
1030
|
+
const lines = relevant.map((s) => `- ${s.name}: ${s.description.length > 140 ? s.description.slice(0, 139).trim() + '…' : s.description}`).join('\n');
|
|
1031
|
+
out += `\n\nLIKELY RELEVANT TO THIS REQUEST (matched from the full ${all.length}-skill library):\n${lines}\n` +
|
|
1032
|
+
`Treat a name here exactly like a SKILLS match above: use_skill it before starting unless it's clearly a false match for what you're about to do.`;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return out;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Which external accounts are connected right now, so the agent knows what it
|
|
1040
|
+
* can actually reach instead of guessing (or claiming access it doesn't have).
|
|
1041
|
+
*/
|
|
1042
|
+
function connectedServicesSection() {
|
|
1043
|
+
const lines = [];
|
|
1044
|
+
const describe = (name, label, detail) => {
|
|
1045
|
+
let connected = false;
|
|
1046
|
+
try { connected = config.isIntegrationConnected(name); } catch {}
|
|
1047
|
+
lines.push(`- ${label}: ${connected ? `connected${detail ? ` (${detail})` : ''}` : 'not connected'}`);
|
|
1048
|
+
};
|
|
1049
|
+
let vercelUser = '', githubUser = '', gmailUser = '';
|
|
1050
|
+
try { vercelUser = config.getIntegration('vercel').userName; } catch {}
|
|
1051
|
+
try { githubUser = config.getIntegration('github').userName; } catch {}
|
|
1052
|
+
try { gmailUser = config.getIntegration('gmail').email; } catch {}
|
|
1053
|
+
describe('supabase', 'Supabase', 'full Management API via supabase_api, SQL via supabase_sql');
|
|
1054
|
+
describe('vercel', 'Vercel', [vercelUser, 'full REST API via vercel_api'].filter(Boolean).join(' - '));
|
|
1055
|
+
describe('github', 'GitHub', githubUser);
|
|
1056
|
+
describe('gmail', 'Gmail', gmailUser);
|
|
1057
|
+
describe('slack', 'Slack', '');
|
|
1058
|
+
return `CONNECTED SERVICES\n${lines.join('\n')}\nA service marked "not connected" cannot be used - tell the user to connect it from Connect Apps rather than attempting it.`;
|
|
404
1059
|
}
|
|
405
1060
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1061
|
+
const ROLE_INTRO = (role) =>
|
|
1062
|
+
`CURRENT ROLE: ${role.tagline}\nYou are still Codeply - the single agent doing all of this work yourself. For this task you are working in the ${role.tagline} role; the guide below is the standard to hold your work to. It is a way of working, not a separate agent: never mention handing off or delegating.`;
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* @param {string} mode Build | Plan | Ask
|
|
1066
|
+
* @param {string} cwd
|
|
1067
|
+
* @param {string} userMessage used to pick relevant skills
|
|
1068
|
+
* @param {object} [opts]
|
|
1069
|
+
* @param {string} [opts.roleId] a role from lib/subagents.js (frontend, backend, ...) to take on this turn
|
|
1070
|
+
* @param {string} [opts.goal] the overall objective when running under /goal
|
|
1071
|
+
*/
|
|
1072
|
+
const MAX_MCP_TOOLS_LISTED = 80;
|
|
1073
|
+
|
|
1074
|
+
/** Which MCP servers are connected and what their tools do. */
|
|
1075
|
+
function mcpServersSection(list, native) {
|
|
1076
|
+
if (!Array.isArray(list) || !list.length) return '';
|
|
1077
|
+
const lines = [];
|
|
1078
|
+
let shown = 0;
|
|
1079
|
+
for (const s of list) {
|
|
1080
|
+
if (s.error) { lines.push(`- ${s.name}: NOT CONNECTED (${s.error.slice(0, 160)})`); continue; }
|
|
1081
|
+
lines.push(`- ${s.name}${s.instructions ? ` - ${s.instructions.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`);
|
|
1082
|
+
for (const t of s.tools) {
|
|
1083
|
+
if (shown++ >= MAX_MCP_TOOLS_LISTED) break;
|
|
1084
|
+
const params = Object.keys(t.inputSchema?.properties || {});
|
|
1085
|
+
lines.push(` ${t.name}(${params.join(', ')}): ${String(t.description || '').replace(/\s+/g, ' ').slice(0, 150)}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
const how = native
|
|
1089
|
+
? 'Each tool above is also available to you directly as mcp__<server>__<tool>.'
|
|
1090
|
+
: 'Call one with <codeply:mcp> <server>name</server> <tool>tool</tool> <args>{"param": "value"}</args> </codeply:mcp> (args is a JSON object).';
|
|
1091
|
+
return `MCP SERVERS\nTools from the user's connected MCP servers. ${how}\n${lines.join('\n')}`;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export function buildSystemPrompt(mode, cwd, userMessage, opts = {}) {
|
|
1095
|
+
const parts = [MODE_PROMPT[mode] || MODE_PROMPT.Build];
|
|
1096
|
+
// The role guide goes right after the base framing and before the tool
|
|
1097
|
+
// reference, so it colors how every tool gets used.
|
|
1098
|
+
const role = opts.roleId ? rolesLib.getSubagent(opts.roleId) : null;
|
|
1099
|
+
if (role) parts.push('', ROLE_INTRO(role), role.persona);
|
|
1100
|
+
if (opts.goal) {
|
|
1101
|
+
parts.push('', `GOAL MODE\nYou are working autonomously toward this goal until it is fully achieved:\n"${opts.goal}"\n` +
|
|
1102
|
+
'Keep going without asking the user questions - make sensible, conventional decisions yourself and note them in your summary. ' +
|
|
1103
|
+
'Only stop to ask if you are truly blocked (missing credentials, an irreversible choice only the user can make).');
|
|
1104
|
+
}
|
|
1105
|
+
// Native mode swaps the tag examples for a short note; the tools themselves
|
|
1106
|
+
// arrive as function definitions. The RULES apply either way.
|
|
1107
|
+
parts.push('', opts.native ? `${NATIVE_FORMAT}\n\n${TOOL_REFERENCE.slice(TOOL_REFERENCE.indexOf('RULES\n'))}` : TOOL_REFERENCE);
|
|
1108
|
+
parts.push('', connectedServicesSection());
|
|
1109
|
+
if (mode === 'Plan') {
|
|
1110
|
+
parts.push('', 'MODE RESTRICTION: this is Plan mode. write_file and edit_file only work on plan files under .codeply/plans/ (*.md); everything else is disabled and returns an error. run is limited to read-only commands. Call plan_exit when the plan file is written.');
|
|
1111
|
+
} else if (READ_ONLY_MODES.has(mode)) {
|
|
409
1112
|
parts.push('', 'MODE RESTRICTION: write_file and edit_file are disabled. Using them returns an error. run is limited to read-only commands.');
|
|
1113
|
+
} else if (mode === 'Build') {
|
|
1114
|
+
parts.push('', 'PLANS: if the user points you at a plan file under .codeply/plans/, read it and carry it out step by step, keeping its checklist in todo. plan_enter switches to Plan mode; use it only when the user asks to plan first, or the request is large and ambiguous enough that building without a plan would be a gamble. It asks the user before switching.');
|
|
410
1115
|
}
|
|
411
|
-
const skillIndex = buildSkillIndex();
|
|
1116
|
+
const skillIndex = buildSkillIndex(userMessage, cwd);
|
|
412
1117
|
if (skillIndex) parts.push('', skillIndex);
|
|
1118
|
+
const instructions = instructionsSection(cwd);
|
|
1119
|
+
if (instructions) parts.push('', instructions);
|
|
1120
|
+
const mcpSection = mcpServersSection(opts.mcp, opts.native);
|
|
1121
|
+
if (mcpSection) parts.push('', mcpSection);
|
|
413
1122
|
parts.push('', 'PROJECT CONTEXT', buildProjectContext(cwd));
|
|
414
1123
|
return parts.join('\n');
|
|
415
1124
|
}
|
|
@@ -420,23 +1129,176 @@ export function buildSystemPrompt(mode, cwd) {
|
|
|
420
1129
|
* Keep the transcript under budget by dropping the oldest tool exchanges.
|
|
421
1130
|
* The system prompt and the most recent turns always survive.
|
|
422
1131
|
*/
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1132
|
+
// A message's content is a plain string for every ordinary turn, but an
|
|
1133
|
+
// OpenAI-shaped array (text + image_url parts) whenever the user pasted an
|
|
1134
|
+
// image - .length and .startsWith exist on arrays too, just meaning
|
|
1135
|
+
// something completely different (part count, not a function), so treating
|
|
1136
|
+
// content as "always a string" here would either miscount the budget or
|
|
1137
|
+
// throw outright the first time an image-bearing message reached this
|
|
1138
|
+
// function. contentLength() and isToolResult() are what keep this safe.
|
|
1139
|
+
function contentLength(content) {
|
|
1140
|
+
return typeof content === 'string' ? content.length : JSON.stringify(content).length;
|
|
1141
|
+
}
|
|
1142
|
+
// Text of a tool-result message, whether it's a plain string or the
|
|
1143
|
+
// image-bearing [{type:'text',...}, {type:'image_url',...}] array a
|
|
1144
|
+
// browser_check-with-screenshot result now produces (see the tool_result
|
|
1145
|
+
// push above). Screenshots are large - a single one in base64 can outweigh
|
|
1146
|
+
// the whole char budget on its own - so they must be just as trimmable as
|
|
1147
|
+
// any other aging tool result, not permanently exempt because the shape
|
|
1148
|
+
// changed from a plain string.
|
|
1149
|
+
function toolResultText(content) {
|
|
1150
|
+
if (typeof content === 'string') return content;
|
|
1151
|
+
if (Array.isArray(content)) return content.find((p) => p.type === 'text')?.text || '';
|
|
1152
|
+
return '';
|
|
1153
|
+
}
|
|
1154
|
+
function isToolResult(content) {
|
|
1155
|
+
return toolResultText(content).startsWith('[tool result');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function trimTranscript(messages, budget = CONTEXT_CHAR_BUDGET) {
|
|
1159
|
+
let total = messages.reduce((n, m) => n + contentLength(m.content), 0);
|
|
1160
|
+
if (total <= budget) return messages;
|
|
426
1161
|
|
|
427
1162
|
const kept = [...messages];
|
|
428
1163
|
// index 0 is the system prompt; never touch the last 6 messages.
|
|
429
|
-
for (let i = 1; i < kept.length - 6 && total >
|
|
430
|
-
if (kept[i].role === 'user' && kept[i].content
|
|
431
|
-
total -= kept[i].content
|
|
432
|
-
const head = kept[i].content.slice(0, 200);
|
|
1164
|
+
for (let i = 1; i < kept.length - 6 && total > budget; i++) {
|
|
1165
|
+
if (kept[i].role === 'user' && isToolResult(kept[i].content)) {
|
|
1166
|
+
total -= contentLength(kept[i].content);
|
|
1167
|
+
const head = toolResultText(kept[i].content).slice(0, 200);
|
|
433
1168
|
kept[i] = { role: 'user', content: `${head}\n[… older tool output dropped to save context]` };
|
|
434
|
-
total += kept[i].content
|
|
1169
|
+
total += contentLength(kept[i].content);
|
|
435
1170
|
}
|
|
436
1171
|
}
|
|
437
1172
|
return kept;
|
|
438
1173
|
}
|
|
439
1174
|
|
|
1175
|
+
function transcriptLength(messages) {
|
|
1176
|
+
return messages.reduce((n, m) => n + contentLength(m.content), 0);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ─── Compaction ─────────────────────────────────────────────────────────────
|
|
1180
|
+
//
|
|
1181
|
+
// Dropping old tool output (trimTranscript) is enough most of the time. When
|
|
1182
|
+
// the transcript is STILL over budget after that, the middle of the turn is
|
|
1183
|
+
// replaced by a structured summary written by the model itself, so the goal,
|
|
1184
|
+
// decisions and file paths survive instead of just falling off the end.
|
|
1185
|
+
// The summary template is adapted from opencode (packages/core/src/session/
|
|
1186
|
+
// compaction.ts), MIT License, Copyright (c) 2025 opencode.
|
|
1187
|
+
|
|
1188
|
+
const KEEP_RECENT_MESSAGES = 6;
|
|
1189
|
+
const SUMMARY_SOURCE_CHARS = 2000; // per message fed to the summarizer
|
|
1190
|
+
|
|
1191
|
+
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
|
1192
|
+
<template>
|
|
1193
|
+
## Objective
|
|
1194
|
+
- [one or two brief sentences describing what the user is trying to accomplish]
|
|
1195
|
+
|
|
1196
|
+
## Important Details
|
|
1197
|
+
- [constraints/preferences, decisions and why, important facts, exact context needed to continue, or "(none)"]
|
|
1198
|
+
|
|
1199
|
+
## Work State
|
|
1200
|
+
### Completed
|
|
1201
|
+
- [finished work, verified facts, or changes made, ONLY if a tool result below confirms it; otherwise "(none)"]
|
|
1202
|
+
|
|
1203
|
+
### Active
|
|
1204
|
+
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
|
1205
|
+
|
|
1206
|
+
### Blocked
|
|
1207
|
+
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
|
1208
|
+
|
|
1209
|
+
## Next Move
|
|
1210
|
+
1. [immediate concrete action, or "(none)"]
|
|
1211
|
+
|
|
1212
|
+
## Relevant Files
|
|
1213
|
+
- [file or directory path: why it matters, or "(none)"]
|
|
1214
|
+
</template>
|
|
1215
|
+
|
|
1216
|
+
Rules:
|
|
1217
|
+
- Keep every section, even when empty. Terse bullets, not paragraphs.
|
|
1218
|
+
- Preserve exact file paths, symbols, commands, error strings and identifiers.
|
|
1219
|
+
- A change counts as Completed only if a [tool result] in the conversation shows it succeeded. Something the assistant only said it would do, or said it did without a matching result, goes under Active.
|
|
1220
|
+
- Do not mention the summary process.`;
|
|
1221
|
+
|
|
1222
|
+
function summarySource(messages) {
|
|
1223
|
+
return messages.map((m) => {
|
|
1224
|
+
const text = typeof m.content === 'string' ? m.content : toolResultText(m.content);
|
|
1225
|
+
const cut = text.length > SUMMARY_SOURCE_CHARS ? `${text.slice(0, SUMMARY_SOURCE_CHARS)} [...]` : text;
|
|
1226
|
+
return `${m.role === 'assistant' ? 'ASSISTANT' : 'USER'}: ${cut}`;
|
|
1227
|
+
}).join('\n\n');
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Replace messages[1 .. length-KEEP_RECENT_MESSAGES) with one summary message,
|
|
1232
|
+
* in place. The system prompt, the user's request and the latest steps stay.
|
|
1233
|
+
* @returns {Promise<boolean>} whether anything was compacted
|
|
1234
|
+
*/
|
|
1235
|
+
/**
|
|
1236
|
+
* Used when the summarizer call fails or comes back empty: a plain summary
|
|
1237
|
+
* built from what the tools really did. Worse prose than the model's, but it
|
|
1238
|
+
* can never invent anything, and it still frees the room. (Hermes Agent does
|
|
1239
|
+
* the same, MIT, Nous Research.)
|
|
1240
|
+
*/
|
|
1241
|
+
function fallbackSummary(userMessage, actions) {
|
|
1242
|
+
const changed = [...new Set(actions.filter((a) => a.ok && /^(write_file|edit_file)$/.test(a.tool)).map((a) => a.label))];
|
|
1243
|
+
const failed = actions.filter((a) => !a.ok || (a.exitCode !== undefined && a.exitCode !== 0)).slice(-8);
|
|
1244
|
+
return [
|
|
1245
|
+
'## Objective', `- ${String(typeof userMessage === 'string' ? userMessage : '').slice(0, 400) || '(see request)'}`,
|
|
1246
|
+
'', '## Work State', '### Completed',
|
|
1247
|
+
...(changed.length ? changed.map((f) => `- changed ${f}`) : ['- (none)']),
|
|
1248
|
+
'', '### Blocked',
|
|
1249
|
+
...(failed.length ? failed.map((a) => `- ${a.tool} ${a.label} failed${a.exitCode !== undefined ? ` (exit ${a.exitCode})` : ''}`) : ['- (none)']),
|
|
1250
|
+
'', '## Steps so far', ...actions.slice(-25).map((a) => `- ${a.tool} ${a.label}${a.ok ? '' : ' (FAILED)'}`),
|
|
1251
|
+
].join('\n');
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function compactTranscript(messages, userMessage, signal, route, { actions = [], todos = [] } = {}) {
|
|
1255
|
+
const start = 1;
|
|
1256
|
+
const end = messages.length - KEEP_RECENT_MESSAGES;
|
|
1257
|
+
if (end - start < 4) return false;
|
|
1258
|
+
const prompt = [
|
|
1259
|
+
'You are a context summarization agent. Produce a structured summary so another coding agent can continue this work. ' +
|
|
1260
|
+
'Do not continue the conversation or answer questions in it.',
|
|
1261
|
+
`The user's request for this turn:\n${typeof userMessage === 'string' ? userMessage : ''}`,
|
|
1262
|
+
`Here is the conversation so far:\n\n<conversation>\n${summarySource(messages.slice(start, end))}\n</conversation>`,
|
|
1263
|
+
SUMMARY_TEMPLATE,
|
|
1264
|
+
].join('\n\n');
|
|
1265
|
+
let result = null;
|
|
1266
|
+
try { result = await ai.chat([{ role: 'user', content: prompt }], { signal, route }); } catch {}
|
|
1267
|
+
if (signal?.aborted) return false;
|
|
1268
|
+
let summary = result?.success ? String(result.data?.choices?.[0]?.message?.content || '').trim() : '';
|
|
1269
|
+
// Any action block in a summary would be read as a request later on.
|
|
1270
|
+
summary = summary.replace(/<\/?codeply:[a-z_]+>/gi, '');
|
|
1271
|
+
if (!summary) summary = fallbackSummary(userMessage, actions);
|
|
1272
|
+
const open = formatTodos(todos, { openOnly: true });
|
|
1273
|
+
messages.splice(start, end - start, {
|
|
1274
|
+
role: 'user',
|
|
1275
|
+
content: `[context summary] Reference only: earlier steps of this turn, summarized to save room. Their tool ` +
|
|
1276
|
+
'results are gone, so re-read a file if you need its exact content. Your actions all still work: keep going ' +
|
|
1277
|
+
'from "Next Move" with action blocks, and do not redo what is listed as completed.\n\n' + summary +
|
|
1278
|
+
(open ? `\n\n[Your open task list, kept across the summary]\n${open}` : ''),
|
|
1279
|
+
});
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Once old tool output has been dropped, the model can no longer see what it
|
|
1285
|
+
* already did, which is exactly when it starts redoing steps or "remembering"
|
|
1286
|
+
* work that never happened. A compact record of this turn's real actions,
|
|
1287
|
+
* straight from the tool results, is sent along with every trimmed request.
|
|
1288
|
+
*/
|
|
1289
|
+
function actionLedger(actions, todos = []) {
|
|
1290
|
+
if (!actions.length) return null;
|
|
1291
|
+
const rows = actions.slice(-40).map((a, i) =>
|
|
1292
|
+
`${i + 1}. ${a.tool} ${a.label}${a.ok ? '' : ' (FAILED)'}${a.exitCode !== undefined ? ` exit ${a.exitCode}` : ''}`);
|
|
1293
|
+
const open = formatTodos(todos, { openOnly: true });
|
|
1294
|
+
return {
|
|
1295
|
+
role: 'user',
|
|
1296
|
+
content: `[system] Record of what actually ran so far this turn (older output was trimmed). ` +
|
|
1297
|
+
`This is the ground truth; anything not listed here did not happen:\n${rows.join('\n')}` +
|
|
1298
|
+
(open ? `\n\nOpen items on your task list:\n${open}` : ''),
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
440
1302
|
// ─── Model call ─────────────────────────────────────────────────────────────
|
|
441
1303
|
|
|
442
1304
|
/**
|
|
@@ -454,18 +1316,80 @@ function isNativeToolCallError(error) {
|
|
|
454
1316
|
|
|
455
1317
|
const NO_NATIVE_TOOLS_REMINDER =
|
|
456
1318
|
'[system] Your last reply tried to invoke a function through the API. That is not available here and the response was discarded. ' +
|
|
457
|
-
'Write the action block as literal text in your message
|
|
1319
|
+
'Write the action block as literal text in your message - the tags exactly as shown in the OUTPUT FORMAT section - and nothing else.';
|
|
458
1320
|
|
|
459
|
-
|
|
1321
|
+
// ─── Native tool calling: which models get it ──────────────────────────────
|
|
1322
|
+
// Auto (the hosted model behind a proxy that declares no tools) stays on text
|
|
1323
|
+
// actions. User-added models and the CLI's own providers use native calls
|
|
1324
|
+
// unless their entry says toolMode: 'text', or CODEPLY_TOOLS=text is set.
|
|
1325
|
+
// A model that rejects tools is remembered for the rest of the session.
|
|
1326
|
+
const nativeUnsupported = new Set();
|
|
1327
|
+
const MUTATING_ACTIONS = new Set(['write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo']);
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Native function names for MCP tools (mcp__server__tool, within the 64-char
|
|
1331
|
+
* [A-Za-z0-9_-] limit APIs enforce) and the maps between them and the
|
|
1332
|
+
* engine's single <codeply:mcp> action.
|
|
1333
|
+
*/
|
|
1334
|
+
function mcpNativeNames(list) {
|
|
1335
|
+
const schemas = [];
|
|
1336
|
+
const fromNative = {}; // native name -> { server, tool }
|
|
1337
|
+
const toNative = {}; // "server/tool" -> native name
|
|
1338
|
+
const clean = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, '_');
|
|
1339
|
+
for (const s of list || []) {
|
|
1340
|
+
if (s.error) continue;
|
|
1341
|
+
for (const t of s.tools) {
|
|
1342
|
+
let name = `mcp__${clean(s.name)}__${clean(t.name)}`.slice(0, 64);
|
|
1343
|
+
for (let n = 2; fromNative[name]; n++) name = `${name.slice(0, 60)}_${n}`;
|
|
1344
|
+
fromNative[name] = { server: s.name, tool: t.name };
|
|
1345
|
+
toNative[`${s.name}/${t.name}`] = name;
|
|
1346
|
+
const params = t.inputSchema && t.inputSchema.type === 'object' ? t.inputSchema : { type: 'object', properties: {} };
|
|
1347
|
+
schemas.push({ type: 'function', function: { name, description: `[${s.name}] ${String(t.description || t.name).slice(0, 900)}`, parameters: params } });
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return { schemas, fromNative, toNative };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function routeKey(route) {
|
|
1354
|
+
if (route?.custom) return `custom:${route.custom.id || route.custom.model}`;
|
|
1355
|
+
if (route?.auto) return 'auto';
|
|
1356
|
+
try { const c = config.getConfig(); return `cli:${c.provider}:${c[c.provider]?.model || ''}`; } catch { return 'cli'; }
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function wantsNativeTools(route) {
|
|
1360
|
+
if (process.env.CODEPLY_TOOLS === 'text') return false;
|
|
1361
|
+
if (route?.auto) return false;
|
|
1362
|
+
if (route?.custom) return route.custom.toolMode !== 'text';
|
|
1363
|
+
let c;
|
|
1364
|
+
try { c = config.getConfig(); } catch { return false; }
|
|
1365
|
+
return !!c && !!c.provider && c.provider !== 'codeply' && c.toolMode !== 'text';
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
async function callModel(messages, promptText, signal, route, tools = null, mcpToNative = null) {
|
|
460
1369
|
const meta = promptText ? { promptText } : undefined;
|
|
461
|
-
|
|
1370
|
+
if (tools) {
|
|
1371
|
+
// Native mode: same engine history, translated at the edge (native-tools.mjs).
|
|
1372
|
+
const result = await ai.chat(toNativeMessages(messages, parseReply, mcpToNative), { meta, signal, route, tools });
|
|
1373
|
+
return { result, messages };
|
|
1374
|
+
}
|
|
1375
|
+
let result = await ai.chat(messages, { meta, signal, route });
|
|
462
1376
|
if (result.success || !isNativeToolCallError(result.error)) return { result, messages };
|
|
463
1377
|
|
|
464
1378
|
const corrected = [...messages, { role: 'user', content: NO_NATIVE_TOOLS_REMINDER }];
|
|
465
|
-
result = await ai.chat(corrected, { meta });
|
|
1379
|
+
result = await ai.chat(corrected, { meta, signal, route });
|
|
466
1380
|
return { result, messages: corrected };
|
|
467
1381
|
}
|
|
468
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* House style: no em dashes in anything the user reads. The prompt asks for
|
|
1385
|
+
* that too, but models slip, so prose is cleaned before it's shown. Fenced
|
|
1386
|
+
* code is left exactly as written.
|
|
1387
|
+
*/
|
|
1388
|
+
function withoutEmDashes(text) {
|
|
1389
|
+
return text.split(/(```[\s\S]*?```)/).map((part, i) => (i % 2 === 1 ? part
|
|
1390
|
+
: part.replace(/ \u2014 /g, ', ').replace(/\u2014/g, '-'))).join('');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
469
1393
|
// ─── Loop ───────────────────────────────────────────────────────────────────
|
|
470
1394
|
|
|
471
1395
|
/**
|
|
@@ -477,27 +1401,110 @@ async function callModel(messages, promptText) {
|
|
|
477
1401
|
* @param {string} o.mode Build | Plan | Ask
|
|
478
1402
|
* @param {string} o.cwd
|
|
479
1403
|
* @param {Function} o.approve async ({tool,title,detail,danger}) => 'once'|'always'|'reject'
|
|
480
|
-
* @param {
|
|
481
|
-
*
|
|
1404
|
+
* @param {Function} [o.browser] async (url, {wait}) => report - optional; only a host with a
|
|
1405
|
+
* real browser (the Codeply Craft desktop app) provides this.
|
|
1406
|
+
* Its absence (plain CLI) is what makes browser_check gracefully
|
|
1407
|
+
* report itself unavailable rather than erroring the whole turn.
|
|
1408
|
+
* @param {object} [o.route] per-turn model route: {auto:true} for the hosted model, or
|
|
1409
|
+
* {custom:<model entry>} for a user-added model (see ai.js chat()).
|
|
1410
|
+
* Omitted = the stored CLI provider config.
|
|
1411
|
+
* @param {string[]} [o.images] data: URLs of images pasted alongside this message - only sent
|
|
1412
|
+
* on THIS turn (not replayed into history on later turns); silently
|
|
1413
|
+
* ignored by any model/provider that doesn't accept vision input.
|
|
1414
|
+
* @param {AbortSignal} o.signal a real AbortSignal - it is handed straight to fetch()
|
|
1415
|
+
* in ai.js, which requires an actual instance, not a
|
|
1416
|
+
* look-alike {aborted} object.
|
|
1417
|
+
* @param {string} [o.roleId] a role from lib/subagents.js (frontend, backend, security, ...)
|
|
1418
|
+
* the agent takes on for this turn - its guide is injected into
|
|
1419
|
+
* the system prompt. Unknown/omitted = no role guide.
|
|
1420
|
+
* @param {string} [o.goal] the overall objective when running under /goal.
|
|
1421
|
+
* @param {number} [o.maxSteps] step budget for this turn (default MAX_STEPS).
|
|
1422
|
+
* @param {boolean} [o.verifyOnly] a checking turn (task verification pass, /goal check): its
|
|
1423
|
+
* replies describe work done in EARLIER turns, so the
|
|
1424
|
+
* "claims an edit it didn't make this turn" check is skipped.
|
|
1425
|
+
* @yields {{type:string, ...}} text | reasoning | tool_start | tool_end | done | error | aborted
|
|
482
1426
|
*/
|
|
483
|
-
export async function* runAgent({ userMessage, history, mode, cwd, approve, signal }) {
|
|
484
|
-
|
|
1427
|
+
export async function* runAgent({ userMessage, history, mode, cwd, approve, browser, images, signal, route, roleId, goal, maxSteps, verifyOnly }) {
|
|
1428
|
+
let readOnly = READ_ONLY_MODES.has(mode);
|
|
1429
|
+
const stepBudget = Math.max(1, maxSteps || MAX_STEPS);
|
|
1430
|
+
// OpenAI-shaped content array only when there's actually an image to carry -
|
|
1431
|
+
// every ordinary turn keeps the plain string content every other code path
|
|
1432
|
+
// (trimTranscript, prompt caching, dedup keys) already assumes.
|
|
1433
|
+
const userContent = images && images.length
|
|
1434
|
+
? [{ type: 'text', text: userMessage }, ...images.map((dataUrl) => ({ type: 'image_url', image_url: { url: dataUrl } }))]
|
|
1435
|
+
: userMessage;
|
|
1436
|
+
// Native function calling when the model supports it (see native-tools.mjs).
|
|
1437
|
+
const nativeKey = routeKey(route);
|
|
1438
|
+
let native = wantsNativeTools(route) && !nativeUnsupported.has(nativeKey);
|
|
1439
|
+
// MCP servers the user configured for this project (connections are reused).
|
|
1440
|
+
let mcpList = [];
|
|
1441
|
+
if (Object.keys(mcpLib.loadServers(cwd)).length) {
|
|
1442
|
+
try { mcpList = await mcpLib.listServers(cwd); } catch {}
|
|
1443
|
+
const down = mcpList.filter((s) => s.error);
|
|
1444
|
+
if (down.length) yield { type: 'notice', level: 'warn', text: `MCP: couldn't connect to ${down.map((s) => s.name).join(', ')}. ${down[0].error.slice(0, 160)}` };
|
|
1445
|
+
}
|
|
1446
|
+
const mcpNames = mcpNativeNames(mcpList);
|
|
1447
|
+
// Plan mode keeps write_file/edit_file (only the plan file is writable, enforced
|
|
1448
|
+
// in tools.mjs); plan_exit exists only in Plan and plan_enter only in Build.
|
|
1449
|
+
const schemasFor = (m) => {
|
|
1450
|
+
const ro = READ_ONLY_MODES.has(m);
|
|
1451
|
+
const entries = Object.entries(PARAMS).filter(([n]) => {
|
|
1452
|
+
if (n === 'plan_exit') return m === 'Plan';
|
|
1453
|
+
if (n === 'plan_enter') return m === 'Build';
|
|
1454
|
+
if (ro && MUTATING_ACTIONS.has(n)) return m === 'Plan' && (n === 'write_file' || n === 'edit_file');
|
|
1455
|
+
return true;
|
|
1456
|
+
});
|
|
1457
|
+
return [...buildToolSchemas(Object.fromEntries(entries)), ...mcpNames.schemas];
|
|
1458
|
+
};
|
|
1459
|
+
let toolSchemas = schemasFor(mode);
|
|
485
1460
|
const messages = [
|
|
486
|
-
{ role: 'system', content: buildSystemPrompt(mode, cwd) },
|
|
1461
|
+
{ role: 'system', content: buildSystemPrompt(mode, cwd, userMessage, { roleId, goal, native, mcp: mcpList }) },
|
|
487
1462
|
...history,
|
|
488
|
-
{ role: 'user', content:
|
|
1463
|
+
{ role: 'user', content: userContent },
|
|
489
1464
|
];
|
|
490
1465
|
|
|
491
|
-
|
|
1466
|
+
// fileState: path -> mtime when this turn last read/wrote it (see tools.mjs).
|
|
1467
|
+
const ctx = { cwd, approve, browser, signal, mode, route, fileState: new Map(), ask: typeof approve?.ask === 'function' ? approve.ask : null };
|
|
1468
|
+
const recentCallKeys = []; // executed calls, in order, for the stuck-loop guard
|
|
1469
|
+
const lastResultFor = new Map(); // call key -> its most recent result
|
|
492
1470
|
const transcript = [{ role: 'user', content: userMessage }];
|
|
493
1471
|
let malformedRetries = 0;
|
|
494
1472
|
let truncatedRetries = 0;
|
|
1473
|
+
let hallucinationRetries = 0;
|
|
1474
|
+
let stallNudges = 0;
|
|
1475
|
+
let budgetWarned = false;
|
|
1476
|
+
const openProblems = new Map(); // file -> syntax problems from its latest write/edit
|
|
1477
|
+
const failuresByTool = new Map(); // tool -> consecutive failures (any args)
|
|
1478
|
+
// What really happened this turn, straight from tool results - the ground
|
|
1479
|
+
// truth every completion claim is checked against, and what the host shows
|
|
1480
|
+
// the user as the turn's actual changes.
|
|
1481
|
+
const actions = []; // [{ tool, label, ok, exitCode }]
|
|
1482
|
+
const succeededTools = new Set();
|
|
1483
|
+
let unverifiedEdits = new Set(); // files changed since the last PASSING check (run exit 0 / browser_check)
|
|
1484
|
+
let uncheckedEdits = new Set(); // files changed since the last check ATTEMPT of any outcome
|
|
1485
|
+
let lastRun = null; // { command, exitCode } of the most recent run
|
|
1486
|
+
let verifyNudges = 0;
|
|
1487
|
+
let codeBlockNudged = false;
|
|
1488
|
+
let madeAnyEdit = false; // true once a write_file/edit_file actually succeeds this turn - gates the check below
|
|
1489
|
+
const writtenBasenames = new Set(); // basenames of files actually write_file'd/edit_file'd this turn - feeds the overclaim check below
|
|
1490
|
+
|
|
1491
|
+
// A prompt instruction alone ("call view_images before designing") was not
|
|
1492
|
+
// reliable enough - real runs showed the model calling
|
|
1493
|
+
// design_reference_search, getting real hits back, and then
|
|
1494
|
+
// going straight to write_file without ever looking at a single screenshot
|
|
1495
|
+
// (see the incident that led to this: it cited "productivity app
|
|
1496
|
+
// references" in its summary for a design it never actually viewed). This
|
|
1497
|
+
// turns that into a hard code-level gate instead of a suggestion: once a
|
|
1498
|
+
// reference search returns real hits, writing/editing a UI file is blocked
|
|
1499
|
+
// until view_images has actually run at least once this turn.
|
|
1500
|
+
let referenceSearchPending = false;
|
|
1501
|
+
const UI_FILE_EXT = /\.(html?|css|jsx?|tsx|vue|svelte)$/i;
|
|
495
1502
|
|
|
496
1503
|
// Two mechanisms that cut the actual token COUNT sent, not just its billed
|
|
497
1504
|
// price (prompt caching, elsewhere in ai.js, only does the latter):
|
|
498
1505
|
//
|
|
499
1506
|
// - servedCalls: a repeat read_file/list_dir/search/use_skill/list_skills
|
|
500
|
-
// call within one turn
|
|
1507
|
+
// call within one turn - same tool, same arguments - is a real, common
|
|
501
1508
|
// pattern (the model re-checks something it already has). Re-running it
|
|
502
1509
|
// would resend the exact same content a second time for zero new
|
|
503
1510
|
// information; a short pointer back to the earlier result carries the
|
|
@@ -508,16 +1515,87 @@ export async function* runAgent({ userMessage, history, mode, cwd, approve, sign
|
|
|
508
1515
|
// stale content), keeping it around costs tokens for information that is
|
|
509
1516
|
// now wrong. It gets collapsed to a pointer the moment the edit lands,
|
|
510
1517
|
// not deferred to trimTranscript()'s budget-driven pass.
|
|
511
|
-
const DEDUPABLE = new Set(['read_file', 'list_dir', 'search', 'use_skill', 'list_skills']);
|
|
1518
|
+
const DEDUPABLE = new Set(['read_file', 'list_dir', 'search', 'use_skill', 'list_skills', 'web_fetch', 'web_search']);
|
|
512
1519
|
const servedCalls = new Map(); // callKey -> message index of its result
|
|
513
1520
|
const readIndexByPath = new Map(); // resolved path -> message index of its read_file result
|
|
1521
|
+
// Consecutive edit_file failures per path - a weak model that keeps
|
|
1522
|
+
// guessing at search text instead of re-reading will otherwise loop
|
|
1523
|
+
// through several apologetic retries in a row without ever converging.
|
|
1524
|
+
// Cleared the moment that path gets a real read_file or a successful edit.
|
|
1525
|
+
const failedEditsByPath = new Map();
|
|
1526
|
+
const MAX_EDIT_FAILURES_BEFORE_FORCE_READ = 2;
|
|
1527
|
+
// resolved path -> next unseen line offset, once a read_file call comes back
|
|
1528
|
+
// with more of the file left. A model re-requesting "the rest of this file"
|
|
1529
|
+
// doesn't reliably track and restate the right offset itself - left to that,
|
|
1530
|
+
// a long file often just gets re-shown from the top every time instead of
|
|
1531
|
+
// actually advancing. Consulted (and rewritten) right before a read_file
|
|
1532
|
+
// call executes, below; cleared once a path's edited, same as readIndexByPath.
|
|
1533
|
+
const readProgressByPath = new Map();
|
|
1534
|
+
|
|
1535
|
+
// A model that keeps investigating without ever committing to an action -
|
|
1536
|
+
// reading file after file, re-checking things it already looked at, never
|
|
1537
|
+
// reaching write_file/edit_file/run - otherwise burns the whole MAX_STEPS
|
|
1538
|
+
// budget on research alone and fails with nothing to show for it (the
|
|
1539
|
+
// observed failure this guards against: several read_file calls in a row,
|
|
1540
|
+
// no edit, straight into "Stopped after 24 steps"). DEDUPABLE's own
|
|
1541
|
+
// "unchanged, reusing that result" nudge above only catches an EXACT
|
|
1542
|
+
// repeat of the same call; this also has to count a DEDUPED call's own
|
|
1543
|
+
// "step" toward the streak (it costs nothing extra, but still, correctly,
|
|
1544
|
+
// is a step spent with nothing new to show) or a model that keeps
|
|
1545
|
+
// blindly re-requesting the same thing would burn the whole loop that way
|
|
1546
|
+
// without ever tripping this guard. Any tool not in this set - including a
|
|
1547
|
+
// failed write/edit attempt, which is still a real attempt to act -
|
|
1548
|
+
// resets the streak; only read-only, no-side-effect calls extend it.
|
|
1549
|
+
const NON_PROGRESS_TOOLS = new Set(['read_file', 'list_dir', 'search', 'use_skill', 'list_skills', 'view_images', 'design_reference_search', 'browser_check', 'web_fetch', 'web_search', 'lsp']);
|
|
1550
|
+
const MAX_READ_ONLY_STREAK = 5;
|
|
1551
|
+
let readOnlyStreak = 0;
|
|
1552
|
+
function readOnlyStreakNote() {
|
|
1553
|
+
readOnlyStreak++;
|
|
1554
|
+
if (readOnlyStreak < MAX_READ_ONLY_STREAK) return '';
|
|
1555
|
+
readOnlyStreak = 0;
|
|
1556
|
+
return ` [system] That's ${MAX_READ_ONLY_STREAK} read-only calls in a row with nothing written or run. Stop investigating - either make the actual change now (write_file/edit_file) based on what you've already seen, or state plainly what's blocking you. Do not call another read-only tool this step.`;
|
|
1557
|
+
}
|
|
514
1558
|
|
|
515
1559
|
const callKey = (name, args) => `${name}:${JSON.stringify(args)}`;
|
|
516
1560
|
|
|
517
|
-
for (let step = 0; step <
|
|
1561
|
+
for (let step = 0; step < stepBudget; step++) {
|
|
1562
|
+
if (signal.aborted) { yield { type: 'aborted' }; return; }
|
|
1563
|
+
|
|
1564
|
+
const budget = contextBudgetFor(route);
|
|
1565
|
+
let outgoing = trimTranscript(messages, budget);
|
|
1566
|
+
if (transcriptLength(outgoing) > budget && await compactTranscript(messages, userMessage, signal, route, { actions, todos: ctx.todos || [] })) {
|
|
1567
|
+
// Message positions moved, so the index-based caches below point at the
|
|
1568
|
+
// wrong entries now. They are only shortcuts; starting them over is safe.
|
|
1569
|
+
servedCalls.clear();
|
|
1570
|
+
readIndexByPath.clear();
|
|
1571
|
+
yield { type: 'notice', level: 'info', text: 'Summarized earlier steps of this task to make room.' };
|
|
1572
|
+
outgoing = trimTranscript(messages, budget);
|
|
1573
|
+
}
|
|
1574
|
+
if (outgoing !== messages) {
|
|
1575
|
+
const ledger = actionLedger(actions, ctx.todos || []);
|
|
1576
|
+
if (ledger) outgoing = [...outgoing, ledger];
|
|
1577
|
+
}
|
|
518
1578
|
if (signal.aborted) { yield { type: 'aborted' }; return; }
|
|
519
1579
|
|
|
520
|
-
const
|
|
1580
|
+
const stepStarted = Date.now();
|
|
1581
|
+
let attempt = await callModel(outgoing, userMessage, signal, route, native ? toolSchemas : null, mcpNames.toNative);
|
|
1582
|
+
// The model or its endpoint turned out not to do function calling: drop
|
|
1583
|
+
// to text actions for the rest of this turn (and remember it for later
|
|
1584
|
+
// turns), with the text-format system prompt.
|
|
1585
|
+
if (native && !attempt.result.success && !signal.aborted && TOOLS_UNSUPPORTED.test(String(attempt.result.error || ''))) {
|
|
1586
|
+
native = false;
|
|
1587
|
+
nativeUnsupported.add(nativeKey);
|
|
1588
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(mode, cwd, userMessage, { roleId, goal, native: false, mcp: mcpList }) };
|
|
1589
|
+
outgoing = [messages[0], ...outgoing.slice(1)];
|
|
1590
|
+
yield { type: 'notice', level: 'info', text: 'This model does not support native tool calls, so Codeply switched to text actions.' };
|
|
1591
|
+
attempt = await callModel(outgoing, userMessage, signal, route);
|
|
1592
|
+
}
|
|
1593
|
+
const stepMs = Date.now() - stepStarted;
|
|
1594
|
+
// A real fetch abort (see the signal wiring in ai.js) lands here as a
|
|
1595
|
+
// failed result, not a thrown exception - check the abort flag itself
|
|
1596
|
+
// before treating it as an error worth showing the user, otherwise a
|
|
1597
|
+
// stop mid-generation would flash "AbortError" instead of just stopping.
|
|
1598
|
+
if (signal.aborted) { yield { type: 'aborted' }; return; }
|
|
521
1599
|
const result = attempt.result;
|
|
522
1600
|
if (!result.success) {
|
|
523
1601
|
yield {
|
|
@@ -529,12 +1607,32 @@ export async function* runAgent({ userMessage, history, mode, cwd, approve, sign
|
|
|
529
1607
|
return;
|
|
530
1608
|
}
|
|
531
1609
|
// The correction message, if one was needed, has to stay in the transcript.
|
|
532
|
-
if (attempt.messages
|
|
1610
|
+
if (attempt.messages !== outgoing) {
|
|
533
1611
|
messages.push(attempt.messages[attempt.messages.length - 1]);
|
|
534
1612
|
}
|
|
535
1613
|
|
|
536
|
-
// gpt-oss
|
|
537
|
-
|
|
1614
|
+
// gpt-oss/Laguna put chain-of-thought in a separate field; content can be
|
|
1615
|
+
// null while it's populated. Surfaced as its own event - not appended to
|
|
1616
|
+
// `reply` and not shown inline - so the UI can offer it as an optional,
|
|
1617
|
+
// collapsed "thought for Xs" the user opens on demand instead of dumping
|
|
1618
|
+
// raw reasoning into the chat unconditionally.
|
|
1619
|
+
const reasoning = result.data?.choices?.[0]?.message?.reasoning || '';
|
|
1620
|
+
if (reasoning.trim()) {
|
|
1621
|
+
yield { type: 'reasoning', text: reasoning.trim(), ms: stepMs };
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const replyMsg = result.data?.choices?.[0]?.message || {};
|
|
1625
|
+
let reply = replyMsg.content ?? '';
|
|
1626
|
+
if (Array.isArray(replyMsg.tool_calls) && replyMsg.tool_calls.length) {
|
|
1627
|
+
// Native calls become ordinary action blocks from here on.
|
|
1628
|
+
const conv = toolCallsToTags(reply, replyMsg.tool_calls, PARAMS, mcpNames.fromNative);
|
|
1629
|
+
reply = conv.text;
|
|
1630
|
+
if (conv.problems.length && !reply.includes('<codeply:')) {
|
|
1631
|
+
messages.push({ role: 'assistant', content: reply || '(tool call)' });
|
|
1632
|
+
messages.push({ role: 'user', content: `[system] Your tool call could not be run: ${conv.problems.join('; ')}. Call one of the provided tools with valid arguments.` });
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
538
1636
|
if (!reply.trim()) {
|
|
539
1637
|
messages.push({
|
|
540
1638
|
role: 'user',
|
|
@@ -542,131 +1640,500 @@ export async function* runAgent({ userMessage, history, mode, cwd, approve, sign
|
|
|
542
1640
|
});
|
|
543
1641
|
continue;
|
|
544
1642
|
}
|
|
545
|
-
messages.push({ role: 'assistant', content: reply });
|
|
546
|
-
|
|
547
1643
|
const { prose, calls, recovered, malformed, truncated } = parseReply(reply);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1644
|
+
|
|
1645
|
+
// Which blocks run this step: normally just the first, but a run of
|
|
1646
|
+
// look-around blocks (reads, listings, searches) goes together, since none
|
|
1647
|
+
// of them changes anything and each would otherwise cost a full round trip.
|
|
1648
|
+
const toRun = [];
|
|
1649
|
+
if (calls.length) {
|
|
1650
|
+
toRun.push(calls[0]);
|
|
1651
|
+
if (BATCHABLE_TOOLS.has(calls[0].name)) {
|
|
1652
|
+
for (const c of calls.slice(1)) {
|
|
1653
|
+
if (!BATCHABLE_TOOLS.has(c.name) || toRun.length >= MAX_BATCH) break;
|
|
1654
|
+
toRun.push(c);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
551
1657
|
}
|
|
1658
|
+
// Keep only what the model wrote up to the last block that runs. The rest
|
|
1659
|
+
// was written blind, before any result, and leaving it in the history
|
|
1660
|
+
// teaches the model to treat its own guesses as things that happened.
|
|
1661
|
+
const lastEnd = toRun.length ? toRun[toRun.length - 1].end : undefined;
|
|
1662
|
+
messages.push({ role: 'assistant', content: typeof lastEnd === 'number' ? reply.slice(0, lastEnd) : reply });
|
|
552
1663
|
|
|
553
1664
|
if (calls.length === 0) {
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1665
|
+
// Protocol debris first: a truncated or malformed reply's raw text is
|
|
1666
|
+
// fumbled tag syntax, not an answer, and must never reach the user.
|
|
1667
|
+
if (truncated) {
|
|
1668
|
+
if (truncatedRetries < MAX_TRUNCATED_RETRIES) {
|
|
1669
|
+
truncatedRetries++;
|
|
1670
|
+
messages.push({ role: 'user', content: TRUNCATION_CORRECTION });
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
yield {
|
|
1674
|
+
type: 'error',
|
|
1675
|
+
error: 'The model kept running out of room mid-write, even after being told to write less at once. Try a narrower request.',
|
|
1676
|
+
};
|
|
1677
|
+
return { transcript };
|
|
1678
|
+
}
|
|
1679
|
+
if (malformed) {
|
|
1680
|
+
if (malformedRetries < MAX_MALFORMED_RETRIES) {
|
|
1681
|
+
malformedRetries++;
|
|
1682
|
+
messages.push({ role: 'user', content: FORMAT_CORRECTION });
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
yield {
|
|
1686
|
+
type: 'error',
|
|
1687
|
+
error: 'The model kept writing its action in the wrong format, even after being corrected. Try rephrasing the request.',
|
|
1688
|
+
};
|
|
1689
|
+
return { transcript };
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// ── Claim audit ──────────────────────────────────────────────────────
|
|
1693
|
+
// A final answer is checked against what the tools actually did BEFORE
|
|
1694
|
+
// any of it is shown. Corrections are silent: the false claim never
|
|
1695
|
+
// reaches the screen, the model is sent back to either do the work or
|
|
1696
|
+
// restate only what's true.
|
|
1697
|
+
//
|
|
1698
|
+
// 1. Claims a file change. Two shapes: nothing was written this turn at
|
|
1699
|
+
// all, or something was but the claim names more files than were
|
|
1700
|
+
// actually touched ("I've created index.html, styles.css and app.js"
|
|
1701
|
+
// after only index.html was written).
|
|
1702
|
+
// Skipped for read-only and checking turns, whose answers legitimately
|
|
1703
|
+
// describe changes made in earlier turns.
|
|
1704
|
+
// 0. Wrote a tool result itself. Always invented, in any mode.
|
|
1705
|
+
if (FABRICATED_RESULT.test(prose) && hallucinationRetries < MAX_HALLUCINATION_RETRIES) {
|
|
1706
|
+
hallucinationRetries++;
|
|
1707
|
+
messages.push({ role: 'user', content: FABRICATED_RESULT_CORRECTION });
|
|
560
1708
|
continue;
|
|
561
1709
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1710
|
+
|
|
1711
|
+
// 0b. Announced an action and stopped without writing it.
|
|
1712
|
+
if (stallNudges < MAX_STALL_NUDGES && !/\?\s*$/.test(prose) && ANNOUNCED_INTENT.test(prose.slice(-300))) {
|
|
1713
|
+
stallNudges++;
|
|
1714
|
+
messages.push({ role: 'user', content: STALL_CORRECTION });
|
|
565
1715
|
continue;
|
|
566
1716
|
}
|
|
567
|
-
|
|
568
|
-
|
|
1717
|
+
|
|
1718
|
+
// A successful shell command can change files too (sed, a codegen
|
|
1719
|
+
// script, git checkout), so after one the file-claim check can't tell a
|
|
1720
|
+
// true claim from a false one and stays out of it.
|
|
1721
|
+
const ranShell = succeededTools.has('run');
|
|
1722
|
+
if (!readOnly && !verifyOnly && !ranShell && HALLUCINATED_COMPLETION.test(prose)) {
|
|
1723
|
+
const overclaimed = madeAnyEdit
|
|
1724
|
+
? extractClaimedFilenames(prose).filter((f) => !writtenBasenames.has(f))
|
|
1725
|
+
: [];
|
|
1726
|
+
if (!madeAnyEdit || overclaimed.length > 0) {
|
|
1727
|
+
if (hallucinationRetries < MAX_HALLUCINATION_RETRIES) {
|
|
1728
|
+
hallucinationRetries++;
|
|
1729
|
+
messages.push({ role: 'user', content: madeAnyEdit ? overclaimCorrection(overclaimed) : HALLUCINATED_ACTION_CORRECTION });
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
// Out of retries (it may be describing an earlier turn's work):
|
|
1733
|
+
// let it through, flagged, rather than failing the whole run.
|
|
1734
|
+
yield { type: 'notice', level: 'warn', text: 'Heads up: this reply describes file changes that were not made in this turn. Check the "What actually happened" card for what really changed.' };
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// 2. Claims it ran/tested/verified something, or deployed/pushed it,
|
|
1739
|
+
// with no tool that could have done so succeeding this turn.
|
|
1740
|
+
const claimedVerify = CLAIM_VERIFIED.test(prose) && ![...VERIFY_TOOLS].some((t) => succeededTools.has(t));
|
|
1741
|
+
const claimedShip = CLAIM_SHIPPED.test(prose) && ![...SHIP_TOOLS].some((t) => succeededTools.has(t));
|
|
1742
|
+
if (claimedVerify || claimedShip) {
|
|
1743
|
+
if (hallucinationRetries < MAX_HALLUCINATION_RETRIES) {
|
|
1744
|
+
hallucinationRetries++;
|
|
1745
|
+
messages.push({
|
|
1746
|
+
role: 'user',
|
|
1747
|
+
content: unbackedClaimCorrection(claimedShip ? 'deployed, pushed or published something' : 'ran, tested or verified something'),
|
|
1748
|
+
});
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
// Out of retries: let the answer through, but never silently.
|
|
1752
|
+
yield { type: 'notice', level: 'warn', text: 'Heads up: this summary mentions checks or deployments that no tool actually performed this turn. Treat those claims as unverified.' };
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// 3. Verification loop - changed code/pages must be checked before the
|
|
1756
|
+
// turn can end (Build mode only; Plan/Ask never edit).
|
|
1757
|
+
if (!readOnly && verifyNudges < MAX_VERIFY_NUDGES) {
|
|
1758
|
+
// The last command failed and the answer doesn't own up to it.
|
|
1759
|
+
if (lastRun && lastRun.exitCode !== 0 && !/(fail|error|couldn'?t|could not|unable|not (?:pass|work)|broken|exit(?:ed)? (?:with )?code)/i.test(prose)) {
|
|
1760
|
+
verifyNudges++;
|
|
1761
|
+
messages.push({ role: 'user', content: failedCommandCorrection(lastRun.command.slice(0, 120), lastRun.exitCode) });
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
// A file this turn wrote is still failing its syntax check and the
|
|
1765
|
+
// answer doesn't own up to it.
|
|
1766
|
+
const broken = [...openProblems.entries()];
|
|
1767
|
+
// File names are removed first so "broken.js" or "errors.ts" doesn't
|
|
1768
|
+
// count as owning up to the problem.
|
|
1769
|
+
const admitted = /(syntax|error|problem|broken|not (?:fixed|valid))/i.test(prose.replace(FILENAME_TOKEN, ''));
|
|
1770
|
+
if (broken.length && !admitted) {
|
|
1771
|
+
verifyNudges++;
|
|
1772
|
+
messages.push({
|
|
1773
|
+
role: 'user',
|
|
1774
|
+
content: `[system] You are finishing, but the syntax check still fails for ${broken.map(([f]) => f).join(', ')}:\n` +
|
|
1775
|
+
broken.map(([f, p]) => `- ${f}: ${p[0]}`).join('\n') +
|
|
1776
|
+
'\nFix it (edit_file), or if you cannot, say so plainly in your summary.',
|
|
1777
|
+
});
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
// Files changed since the last time anything was checked at all.
|
|
1781
|
+
const needsCheck = [...uncheckedEdits].filter((f) => VERIFIABLE_EXT.test(f));
|
|
1782
|
+
if (needsCheck.length) {
|
|
1783
|
+
verifyNudges++;
|
|
1784
|
+
messages.push({ role: 'user', content: verifyBeforeDoneCorrection(needsCheck.slice(0, 6)) });
|
|
1785
|
+
yield { type: 'verifying', files: needsCheck };
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// 4. Pasted a big code block instead of applying it (asked once).
|
|
1791
|
+
if (!readOnly && !madeAnyEdit && !codeBlockNudged) {
|
|
1792
|
+
const longestBlock = Math.max(0, ...[...prose.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1].split('\n').length));
|
|
1793
|
+
if (longestBlock >= 15) {
|
|
1794
|
+
codeBlockNudged = true;
|
|
1795
|
+
messages.push({ role: 'user', content: CODE_INSTEAD_OF_EDIT_CORRECTION });
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (prose) {
|
|
1802
|
+
const shown = withoutEmDashes(prose);
|
|
1803
|
+
// interim: narration that accompanies an action (shown folded as "Thinking");
|
|
1804
|
+
// otherwise it's the final answer, shown as a normal message.
|
|
1805
|
+
yield { type: 'text', text: shown, interim: calls.length > 0 };
|
|
1806
|
+
transcript.push({ role: 'assistant', content: shown });
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (calls.length === 0) {
|
|
1810
|
+
// No action block, and every check above passed: the turn is finished.
|
|
1811
|
+
yield {
|
|
1812
|
+
type: 'done',
|
|
1813
|
+
steps: step + 1,
|
|
1814
|
+
madeAnyEdit,
|
|
1815
|
+
writtenFiles: [...writtenBasenames],
|
|
1816
|
+
actions,
|
|
1817
|
+
unverifiedFiles: [...unverifiedEdits],
|
|
1818
|
+
};
|
|
569
1819
|
return { transcript };
|
|
570
1820
|
}
|
|
571
1821
|
|
|
572
1822
|
if (signal.aborted) { yield { type: 'aborted' }; return; }
|
|
573
1823
|
|
|
574
|
-
const call = calls[0];
|
|
575
|
-
if (calls.length > 1) {
|
|
576
|
-
messages.push({
|
|
577
|
-
role: 'user',
|
|
578
|
-
content: '[note] You wrote several action blocks at once. Only the first was performed. Write one per reply.',
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
1824
|
if (recovered) {
|
|
582
1825
|
// It worked this time, but only because we guessed. Nudge it back on
|
|
583
1826
|
// format so the next step does not depend on the same guess.
|
|
584
1827
|
messages.push({ role: 'user', content: FORMAT_CORRECTION });
|
|
585
1828
|
}
|
|
586
1829
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
role: 'user',
|
|
590
|
-
content: `[tool result: ${call.name}] Blocked — ${mode} mode cannot modify files. Describe the change instead, or tell the user to press tab for Build mode.`,
|
|
591
|
-
});
|
|
592
|
-
yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: `blocked in ${mode} mode` };
|
|
593
|
-
continue;
|
|
594
|
-
}
|
|
1830
|
+
for (const call of toRun) {
|
|
1831
|
+
if (signal.aborted) { yield { type: 'aborted' }; return; }
|
|
595
1832
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1833
|
+
// A read_file with no <offset> on a path already partway read continues
|
|
1834
|
+
// from where the last call left off, instead of silently restarting at
|
|
1835
|
+
// line 1 - see readProgressByPath above. Only fills in what the model
|
|
1836
|
+
// left unspecified; an explicit offset (including a deliberate 0, to
|
|
1837
|
+
// recheck the top again) always wins.
|
|
1838
|
+
if (call.name === 'read_file' && call.args.path && (call.args.offset === undefined || call.args.offset === null || call.args.offset === '')) {
|
|
1839
|
+
const abs = resolvePath(call.args.path, cwd).abs;
|
|
1840
|
+
if (readProgressByPath.has(abs)) call.args.offset = String(readProgressByPath.get(abs));
|
|
1841
|
+
}
|
|
1842
|
+
const isPlanFileWrite = mode === 'Plan' && (call.name === 'write_file' || call.name === 'edit_file') && isPlanPath(call.args.path, cwd);
|
|
1843
|
+
if (readOnly && (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'apply_patch') && !isPlanFileWrite) {
|
|
1844
|
+
messages.push({
|
|
1845
|
+
role: 'user',
|
|
1846
|
+
content: `[tool result: ${call.name}] Blocked - ${mode} mode cannot modify files${mode === 'Plan' ? ' except the plan file in .codeply/plans/' : ''}. Describe the change instead, or tell the user to press tab for Build mode.`,
|
|
1847
|
+
});
|
|
1848
|
+
yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: `blocked in ${mode} mode` };
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const touchesUiFile = call.name === 'apply_patch'
|
|
1853
|
+
? patchTargets(call.args.patch).some((p) => UI_FILE_EXT.test(p))
|
|
1854
|
+
: (call.name === 'write_file' || call.name === 'edit_file') && UI_FILE_EXT.test(call.args.path || '');
|
|
1855
|
+
if (referenceSearchPending && touchesUiFile) {
|
|
1856
|
+
messages.push({
|
|
1857
|
+
role: 'user',
|
|
1858
|
+
content: `[tool result: ${call.name}] Blocked - you searched for real-app references and got real hits back, but never called view_images ` +
|
|
1859
|
+
`to actually look at any of the screenshot URLs. Call view_images now with 2-4 of those URLs (from different apps), THEN write ${call.args.path || 'the file'}.`,
|
|
1860
|
+
});
|
|
1861
|
+
yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: 'blocked - reference screenshots not viewed yet' };
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// A repeat of an earlier read-only call, same tool and same arguments,
|
|
1866
|
+
// carries zero new information - reuse the earlier result instead of
|
|
1867
|
+
// spending tokens to resend it. This is skipped for read_file specifically
|
|
1868
|
+
// when the file was edited since (see the invalidation below): the whole
|
|
1869
|
+
// point there is to force a fresh read, not to serve stale content.
|
|
1870
|
+
if (DEDUPABLE.has(call.name) && servedCalls.has(callKey(call.name, call.args))) {
|
|
1871
|
+
messages.push({
|
|
1872
|
+
role: 'user',
|
|
1873
|
+
content: `[tool result: ${call.name}] Unchanged since your earlier identical call - reusing that result, not re-run. Nothing new to see; move on.`,
|
|
1874
|
+
});
|
|
1875
|
+
yield {
|
|
1876
|
+
type: 'tool_end', name: call.name, args: call.args, ok: true,
|
|
1877
|
+
meta: { deduped: true }, summary: 'unchanged - reused earlier result',
|
|
1878
|
+
};
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// The same call, same arguments, several times in a row: whatever it
|
|
1883
|
+
// returns will not change, so running it again just burns a step.
|
|
1884
|
+
const key = callKey(call.name, call.args);
|
|
1885
|
+
if (recentCallKeys.length >= STUCK_REPEATS - 1 && recentCallKeys.slice(-(STUCK_REPEATS - 1)).every((k) => k === key)) {
|
|
1886
|
+
recentCallKeys.length = 0;
|
|
1887
|
+
messages.push({ role: 'user', content: stuckCorrection(call.name, lastResultFor.get(key)) });
|
|
1888
|
+
yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: 'skipped - same as the last attempts' };
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
yield { type: 'tool_start', name: call.name, args: call.args };
|
|
1893
|
+
|
|
1894
|
+
const needsApproval = TOOL_NEEDS_APPROVAL.has(call.name);
|
|
1895
|
+
const started = Date.now();
|
|
1896
|
+
const out = await executeTool(call.name, call.args, ctx);
|
|
1897
|
+
recentCallKeys.push(key);
|
|
1898
|
+
lastResultFor.set(key, out);
|
|
1899
|
+
const ms = Date.now() - started;
|
|
1900
|
+
|
|
1901
|
+
yield {
|
|
1902
|
+
type: 'tool_end',
|
|
1903
|
+
name: call.name,
|
|
1904
|
+
args: call.args,
|
|
1905
|
+
ok: out.ok,
|
|
1906
|
+
meta: out.meta,
|
|
1907
|
+
summary: out.meta?.label,
|
|
1908
|
+
ms,
|
|
1909
|
+
needsApproval,
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1912
|
+
// browser_check with a real screenshot attached, or view_images with one
|
|
1913
|
+
// or more fetched images, gets the OpenAI-shaped image content array
|
|
1914
|
+
// (same shape used for pasted user images above) so the model actually
|
|
1915
|
+
// looks at the rendered page / reference screenshots instead of judging
|
|
1916
|
+
// them solely from a text description - that blind verification was
|
|
1917
|
+
// exactly why the same page could get called "the same" or "different"
|
|
1918
|
+
// inconsistently, and why a reference-search result could get cited
|
|
1919
|
+
// without ever actually being looked at. Every other tool, and any
|
|
1920
|
+
// provider that can't take image input, keeps the plain string content
|
|
1921
|
+
// unchanged.
|
|
1922
|
+
const resultText = `[tool result: ${call.name}]\n${out.output}`;
|
|
1923
|
+
const imageUrls = out.meta?.screenshotDataUrl
|
|
1924
|
+
? [out.meta.screenshotDataUrl]
|
|
1925
|
+
: Array.isArray(out.meta?.imageDataUrls) ? out.meta.imageDataUrls : null;
|
|
602
1926
|
messages.push({
|
|
603
1927
|
role: 'user',
|
|
604
|
-
content:
|
|
1928
|
+
content: imageUrls
|
|
1929
|
+
? [{ type: 'text', text: resultText }, ...imageUrls.map((url) => ({ type: 'image_url', image_url: { url } }))]
|
|
1930
|
+
: resultText,
|
|
605
1931
|
});
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1932
|
+
|
|
1933
|
+
// Ground truth for the claim audit above and for the host's "what
|
|
1934
|
+
// actually happened" summary.
|
|
1935
|
+
const exitCode = typeof out.meta?.exitCode === 'number' ? out.meta.exitCode : undefined;
|
|
1936
|
+
const patchFiles = call.name === 'apply_patch' && Array.isArray(out.meta?.files) ? out.meta.files : null;
|
|
1937
|
+
actions.push({
|
|
1938
|
+
tool: call.name,
|
|
1939
|
+
label: String(out.meta?.label || call.args.path || call.args.command || '').slice(0, 200),
|
|
1940
|
+
ok: !!out.ok,
|
|
1941
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
1942
|
+
...(patchFiles ? { files: patchFiles } : {}),
|
|
1943
|
+
});
|
|
1944
|
+
// The plan file is not a change to the project, so it must not count as an edit
|
|
1945
|
+
// (it would otherwise feed the "you claimed a change" audit after the switch to Build).
|
|
1946
|
+
const wasPlanFile = isPlanFileWrite;
|
|
1947
|
+
if (out.ok && patchFiles) {
|
|
1948
|
+
succeededTools.add(call.name);
|
|
1949
|
+
for (const f of patchFiles) {
|
|
1950
|
+
const rel = String(f).replace(/\\/g, '/');
|
|
1951
|
+
unverifiedEdits.add(rel);
|
|
1952
|
+
uncheckedEdits.add(rel);
|
|
1953
|
+
const probs = out.meta?.problemsByFile?.[f];
|
|
1954
|
+
if (probs?.length) openProblems.set(rel, probs);
|
|
1955
|
+
else openProblems.delete(rel);
|
|
1956
|
+
}
|
|
1957
|
+
} else if (out.ok) {
|
|
1958
|
+
succeededTools.add(call.name);
|
|
1959
|
+
if (out.meta?.switchMode && out.meta.switchMode !== mode) {
|
|
1960
|
+
mode = out.meta.switchMode;
|
|
1961
|
+
readOnly = READ_ONLY_MODES.has(mode);
|
|
1962
|
+
ctx.mode = mode;
|
|
1963
|
+
toolSchemas = schemasFor(mode);
|
|
1964
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(mode, cwd, userMessage, { roleId, goal, native, mcp: mcpList }) };
|
|
1965
|
+
yield { type: 'mode_switch', mode, planPath: out.meta.planPath };
|
|
1966
|
+
}
|
|
1967
|
+
if ((call.name === 'write_file' || call.name === 'edit_file' || call.name === 'fetch_image') && !out.meta?.noop && call.args.path && !wasPlanFile) {
|
|
1968
|
+
const rel = String(call.args.path).replace(/\\/g, '/');
|
|
1969
|
+
unverifiedEdits.add(rel);
|
|
1970
|
+
uncheckedEdits.add(rel);
|
|
1971
|
+
if (Array.isArray(out.meta?.problems) && out.meta.problems.length) openProblems.set(rel, out.meta.problems);
|
|
1972
|
+
else openProblems.delete(rel);
|
|
1973
|
+
} else if (call.name === 'run') {
|
|
1974
|
+
lastRun = { command: String(call.args.command || ''), exitCode: exitCode ?? 0 };
|
|
1975
|
+
uncheckedEdits = new Set();
|
|
1976
|
+
if (lastRun.exitCode === 0) unverifiedEdits = new Set();
|
|
1977
|
+
} else if (call.name === 'browser_check') {
|
|
1978
|
+
uncheckedEdits = new Set();
|
|
1979
|
+
unverifiedEdits = new Set();
|
|
1980
|
+
}
|
|
640
1981
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1982
|
+
|
|
1983
|
+
if (out.ok) {
|
|
1984
|
+
if (call.name === 'design_reference_search' && out.meta?.count > 0) {
|
|
1985
|
+
referenceSearchPending = true;
|
|
1986
|
+
}
|
|
1987
|
+
if (call.name === 'view_images') {
|
|
1988
|
+
referenceSearchPending = false;
|
|
1989
|
+
}
|
|
1990
|
+
if (DEDUPABLE.has(call.name)) {
|
|
1991
|
+
servedCalls.set(callKey(call.name, call.args), messages.length - 1);
|
|
1992
|
+
}
|
|
1993
|
+
if (call.name === 'read_file' && call.args.path) {
|
|
1994
|
+
const abs = resolvePath(call.args.path, cwd).abs;
|
|
1995
|
+
readIndexByPath.set(abs, { msgIndex: messages.length - 1, key: callKey(call.name, call.args) });
|
|
1996
|
+
// A fresh read is exactly the course-correction we'd otherwise force -
|
|
1997
|
+
// no need to keep counting failures against this path anymore.
|
|
1998
|
+
failedEditsByPath.delete(abs);
|
|
1999
|
+
if (out.meta?.hasMore) readProgressByPath.set(abs, (out.meta.offset || 0) + (out.meta.linesShown || 0));
|
|
2000
|
+
else readProgressByPath.delete(abs); // the whole file has now been seen at least once
|
|
2001
|
+
}
|
|
2002
|
+
if ((call.name === 'edit_file' || call.name === 'write_file') && !wasPlanFile) {
|
|
2003
|
+
madeAnyEdit = true;
|
|
2004
|
+
if (call.args.path) writtenBasenames.add(path.basename(call.args.path).toLowerCase());
|
|
2005
|
+
}
|
|
2006
|
+
if (patchFiles) {
|
|
2007
|
+
madeAnyEdit = true;
|
|
2008
|
+
for (const f of patchFiles) writtenBasenames.add(path.basename(f).toLowerCase());
|
|
2009
|
+
}
|
|
2010
|
+
// Paths this call just changed: earlier reads of them are now wrong.
|
|
2011
|
+
const changedNow = patchFiles
|
|
2012
|
+
? [...patchFiles, ...patchTargets(call.args.patch)]
|
|
2013
|
+
: ((call.name === 'edit_file' || call.name === 'write_file') && call.args.path ? [call.args.path] : []);
|
|
2014
|
+
for (const p of changedNow) failedEditsByPath.delete(resolvePath(p, cwd).abs);
|
|
2015
|
+
for (const p of changedNow) {
|
|
2016
|
+
// The file just changed, so any earlier read_file result for it is now
|
|
2017
|
+
// wrong, not just old - collapse it in place rather than leaving
|
|
2018
|
+
// outdated content sitting in the transcript for the model to
|
|
2019
|
+
// (mis)reason from, and drop it from the dedup cache so a genuinely
|
|
2020
|
+
// fresh read_file after this point is not short-circuited by it.
|
|
2021
|
+
const abs = resolvePath(p, cwd).abs;
|
|
2022
|
+
const stale = readIndexByPath.get(abs);
|
|
2023
|
+
if (stale) {
|
|
2024
|
+
messages[stale.msgIndex] = {
|
|
2025
|
+
role: 'user',
|
|
2026
|
+
content: `[tool result: read_file] ${p} - superseded by the ${call.name} below; that earlier content no longer matches the file. Re-read if you need to see it again.`,
|
|
2027
|
+
};
|
|
2028
|
+
servedCalls.delete(stale.key);
|
|
2029
|
+
readIndexByPath.delete(abs);
|
|
2030
|
+
}
|
|
2031
|
+
readProgressByPath.delete(abs);
|
|
2032
|
+
}
|
|
2033
|
+
} else if (call.name === 'edit_file' && call.args.path) {
|
|
2034
|
+
// A model that keeps guessing at search text instead of re-reading the
|
|
2035
|
+
// file will otherwise spiral through several apologetic retries in a
|
|
2036
|
+
// row without ever converging on the actual current content. After
|
|
2037
|
+
// enough consecutive failures on the *same* path, stop it from trying
|
|
2038
|
+
// again blind and force a real read_file first.
|
|
647
2039
|
const abs = resolvePath(call.args.path, cwd).abs;
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
2040
|
+
const failures = (failedEditsByPath.get(abs) || 0) + 1;
|
|
2041
|
+
failedEditsByPath.set(abs, failures);
|
|
2042
|
+
if (failures >= MAX_EDIT_FAILURES_BEFORE_FORCE_READ) {
|
|
2043
|
+
failedEditsByPath.set(abs, 0);
|
|
2044
|
+
messages.push({
|
|
651
2045
|
role: 'user',
|
|
652
|
-
content: `[
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
|
|
2046
|
+
content: `[note] That edit_file call on ${call.args.path} has now failed ${failures} times in a row - the search text you're guessing at does not match the file. Do not apologize or re-explain what you were trying to do. Call read_file on ${call.args.path} right now, look at its actual current content, then make the edit with search text copied exactly from it.`,
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// A declined action is the user's answer, not a retry prompt.
|
|
2052
|
+
if (out.meta?.rejected) {
|
|
2053
|
+
messages.push({
|
|
2054
|
+
role: 'user',
|
|
2055
|
+
content: '[note] The user declined that action. Do not retry it. Either continue without it or stop and explain what you would have done.',
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Failure streaks per tool, whatever the arguments. A run that exits
|
|
2060
|
+
// non-zero counts as a failure here even though the tool itself worked.
|
|
2061
|
+
// Any successful change (write/edit, or a passing run) starts over,
|
|
2062
|
+
// since "edit, re-run, still failing" is normal debugging, not a loop.
|
|
2063
|
+
const failed = !out.ok || (call.name === 'run' && out.meta?.exitCode !== 0);
|
|
2064
|
+
if (out.meta?.rejected) {
|
|
2065
|
+
// The user's call, not the model's mistake.
|
|
2066
|
+
} else if (failed) {
|
|
2067
|
+
const n = (failuresByTool.get(call.name) || 0) + 1;
|
|
2068
|
+
failuresByTool.set(call.name, n);
|
|
2069
|
+
if (n === FAILURE_STREAK_WARN || n === FAILURE_STREAK_WARN * 2) {
|
|
2070
|
+
messages.push({ role: 'user', content: failureStreakNote(call.name, n) });
|
|
2071
|
+
}
|
|
2072
|
+
} else {
|
|
2073
|
+
failuresByTool.delete(call.name);
|
|
2074
|
+
if (/^(write_file|edit_file|apply_patch|run)$/.test(call.name)) {
|
|
2075
|
+
failuresByTool.clear();
|
|
2076
|
+
recentCallKeys.length = 0; // a real change breaks any cycle
|
|
656
2077
|
}
|
|
657
2078
|
}
|
|
658
2079
|
}
|
|
659
2080
|
|
|
660
|
-
|
|
661
|
-
if (out.meta?.rejected) {
|
|
2081
|
+
if (calls.length > toRun.length) {
|
|
662
2082
|
messages.push({
|
|
663
2083
|
role: 'user',
|
|
664
|
-
content:
|
|
2084
|
+
content: `[note] You wrote ${calls.length} action blocks at once. Only ${toRun.length === 1 ? 'the first was' : `the first ${toRun.length} (all look-around) were`} performed. ` +
|
|
2085
|
+
'Anything that changes something (write_file, edit_file, run, ...) goes alone in its reply.',
|
|
665
2086
|
});
|
|
666
2087
|
}
|
|
2088
|
+
|
|
2089
|
+
const period = findCycle(recentCallKeys);
|
|
2090
|
+
if (period) {
|
|
2091
|
+
messages.push({ role: 'user', content: cycleNote(period) });
|
|
2092
|
+
recentCallKeys.length = 0;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Counted once per step, not per block, so a batch of reads is one step
|
|
2096
|
+
// of looking around rather than four. The task list is planning, not
|
|
2097
|
+
// looking around, so it neither extends nor resets the streak.
|
|
2098
|
+
const counted = toRun.filter((c) => c.name !== 'todo' && c.name !== 'ask_user');
|
|
2099
|
+
if (!counted.length) {
|
|
2100
|
+
// todo only
|
|
2101
|
+
} else if (counted.every((c) => NON_PROGRESS_TOOLS.has(c.name))) {
|
|
2102
|
+
const note = readOnlyStreakNote().trim();
|
|
2103
|
+
if (note) messages.push({ role: 'user', content: note });
|
|
2104
|
+
} else {
|
|
2105
|
+
readOnlyStreak = 0;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
if (!budgetWarned && stepBudget >= 12 && step + 1 >= stepBudget - 5) {
|
|
2109
|
+
budgetWarned = true;
|
|
2110
|
+
messages.push({ role: 'user', content: budgetCheckpointNote(step + 1, stepBudget) });
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// Out of steps. One last call with actions switched off, so the user gets a
|
|
2115
|
+
// real account of where things stand instead of a bare "stopped". Its
|
|
2116
|
+
// answer is checked the same way: no action blocks, no invented results.
|
|
2117
|
+
if (!signal.aborted && actions.length) {
|
|
2118
|
+
const closing = await callModel([...trimTranscript(messages, contextBudgetFor(route)), { role: 'user', content: FINAL_SUMMARY_PROMPT }], userMessage, signal, route);
|
|
2119
|
+
const text = closing.result?.success ? String(closing.result.data?.choices?.[0]?.message?.content || '') : '';
|
|
2120
|
+
const { prose, calls: stray } = parseReply(text);
|
|
2121
|
+
// Keep only what comes before any action block it wrote anyway.
|
|
2122
|
+
const cleaned = (stray.length ? text.slice(0, Math.max(0, text.search(/<codeply:/i))) : prose).trim();
|
|
2123
|
+
if (cleaned && !FABRICATED_RESULT.test(cleaned)) {
|
|
2124
|
+
const shown = withoutEmDashes(cleaned);
|
|
2125
|
+
yield { type: 'text', text: shown, interim: false };
|
|
2126
|
+
transcript.push({ role: 'assistant', content: shown });
|
|
2127
|
+
}
|
|
667
2128
|
}
|
|
668
2129
|
|
|
669
|
-
yield {
|
|
2130
|
+
yield {
|
|
2131
|
+
type: 'error',
|
|
2132
|
+
error: `Stopped after ${stepBudget} steps without finishing.` +
|
|
2133
|
+
(madeAnyEdit ? ` Changed so far: ${[...writtenBasenames].join(', ')}.` : ' No files were changed.') +
|
|
2134
|
+
' Send a follow-up to continue, or narrow the request.',
|
|
2135
|
+
actions,
|
|
2136
|
+
};
|
|
670
2137
|
}
|
|
671
2138
|
|
|
672
2139
|
export { MAX_STEPS };
|