s-app-scoder-agent 0.9.2 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -4
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /package/dist/{watch.js → workspace/watch.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { claimsCompletion } from "./context.js";
1
+ import { claimsCompletion } from "../context/context.js";
2
2
  /** Marker do system prompt quy ước: lượt chat thuần, không cần build. */
3
3
  export const CHAT_DONE_MARKER = "<<CHAT_DONE>>";
4
4
  export const DEFAULT_GUARDRAIL_OPTIONS = {
@@ -0,0 +1,70 @@
1
+ export class InteractionBroker {
2
+ pendingPermissions = new Map();
3
+ questionHandler = null;
4
+ permissionSequence = 0;
5
+ questionSequence = 0;
6
+ setQuestionHandler(fn) {
7
+ this.questionHandler = fn;
8
+ }
9
+ ask(runtime, request) {
10
+ const handler = this.questionHandler;
11
+ if (!handler) {
12
+ return {
13
+ ok: false,
14
+ output: "ERROR: ask_user is unavailable — non-interactive session. Proceed with your best judgment or stop and report.",
15
+ };
16
+ }
17
+ const id = `q${Date.now()}_${++this.questionSequence}`;
18
+ runtime.emit({ type: "question_request", id, question: request.question, options: request.options, multiple: request.multiple, timeoutSecs: request.timeoutSecs ?? 120 });
19
+ return this.awaitQuestion(runtime, request, id, handler);
20
+ }
21
+ async awaitQuestion(runtime, request, id, handler) {
22
+ try {
23
+ const timeoutMs = (request.timeoutSecs ?? 120) * 1000;
24
+ const answer = await Promise.race([
25
+ handler({ ...request, id }),
26
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
27
+ ]);
28
+ if (answer == null || (typeof answer === "string" && !answer.trim())) {
29
+ return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
30
+ }
31
+ runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
32
+ return { ok: true, output: `User answered: ${answer}` };
33
+ }
34
+ catch (err) {
35
+ return { ok: false, output: `ERROR: ask_user failed: ${err.message.slice(0, 200)}` };
36
+ }
37
+ }
38
+ permissionPrompt(runtime, request) {
39
+ if (!runtime.hasEventSink())
40
+ return Promise.resolve("no");
41
+ const id = `perm-${++this.permissionSequence}`;
42
+ return new Promise((resolve) => {
43
+ const timer = setTimeout(() => {
44
+ if (this.pendingPermissions.delete(id))
45
+ resolve("no");
46
+ }, request.timeoutSecs * 1000);
47
+ this.pendingPermissions.set(id, (answer) => {
48
+ clearTimeout(timer);
49
+ resolve(answer);
50
+ });
51
+ runtime.emit({
52
+ type: "permission_request",
53
+ id,
54
+ kind: request.kind,
55
+ label: request.label,
56
+ ...(request.command !== undefined ? { command: request.command } : {}),
57
+ ...(request.diff !== undefined ? { diff: request.diff } : {}),
58
+ ...(request.alwaysAllowPrefix !== undefined ? { alwaysAllowPrefix: request.alwaysAllowPrefix } : {}),
59
+ timeoutSecs: request.timeoutSecs,
60
+ });
61
+ });
62
+ }
63
+ resolvePermission(id, answer) {
64
+ const resolver = this.pendingPermissions.get(id);
65
+ if (resolver) {
66
+ this.pendingPermissions.delete(id);
67
+ resolver(answer);
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,136 @@
1
+ import { saveApproval } from "../config/config.js";
2
+ import { redactSecrets } from "../llm/llm.js";
3
+ import { allToolSpecs } from "../tools/registry.js";
4
+ import { applyToolFilter } from "./types.js";
5
+ import { McpManager } from "../mcp/manager.js";
6
+ import { validateSchema } from "../shared/json-schema.js";
7
+ export function rebuildToolList(runtime) {
8
+ const manager = runtime.getMcp();
9
+ const mcpSpecs = manager ? [...manager.toToolSpecs(), ...manager.resourceToolSpecs()] : [];
10
+ let base = applyToolFilter(allToolSpecs(), runtime.primaryDef, runtime.reporter);
11
+ if (runtime.pendingAllowedTools && runtime.pendingAllowedTools.length > 0) {
12
+ const allow = new Set(runtime.pendingAllowedTools.map((tool) => tool.toLowerCase()));
13
+ base = base.filter((spec) => allow.has(spec.function.name.toLowerCase()));
14
+ runtime.setTools([...base, ...mcpSpecs.filter((spec) => allow.has(spec.function.name.toLowerCase()))]);
15
+ }
16
+ else {
17
+ runtime.setTools([...base, ...mcpSpecs]);
18
+ }
19
+ }
20
+ export async function connectMcp(runtime) {
21
+ const servers = runtime.config.mcpServers ?? {};
22
+ if (Object.keys(servers).length === 0)
23
+ return;
24
+ if (runtime.getMcp())
25
+ return;
26
+ if (runtime.config.permissionMode === "readonly") {
27
+ runtime.reporter.warn("readonly mode: MCP tools are disabled (they bypass the permission system)");
28
+ }
29
+ const manager = new McpManager(servers);
30
+ runtime.setMcp(manager);
31
+ manager.onStatus = (line) => {
32
+ runtime.logger.log({ type: "mcp_status", line });
33
+ runtime.emit({ type: "mcp_status", line });
34
+ runtime.reporter.info(line);
35
+ };
36
+ manager.onServerState = (name, state, detail) => {
37
+ const line = `MCP "${name}" ${state}${detail ? ` — ${detail}` : ""}`;
38
+ runtime.logger.log({ type: "mcp_status", line, server: name, state });
39
+ runtime.emit({ type: "mcp_status", line, server: name, state });
40
+ };
41
+ await manager.connectAll();
42
+ rebuildToolList(runtime);
43
+ }
44
+ export function shutdownMcp(runtime) {
45
+ runtime.getMcp()?.killAll();
46
+ runtime.setMcp(null);
47
+ }
48
+ export async function invokeMcpTool(runtime, name, args) {
49
+ const registration = runtime.getMcp()?.getTool(name);
50
+ if (!registration)
51
+ return { ok: false, output: `ERROR: unknown tool "${name}"` };
52
+ const schemaErrors = validateSchema(args, registration.def.inputSchema);
53
+ if (schemaErrors.length > 0) {
54
+ return { ok: false, output: `ERROR: invalid arguments for ${name}:\n${schemaErrors.join("\n")}` };
55
+ }
56
+ runtime.logger.log({
57
+ type: "mcp_audit",
58
+ tool: name,
59
+ server: registration.serverName,
60
+ args: redactSecrets(JSON.stringify(args)),
61
+ ts: new Date().toISOString(),
62
+ });
63
+ const result = await registration.conn.callTool(registration.toolName, args);
64
+ return {
65
+ ok: result.ok,
66
+ output: result.output.length > 20_000 ? result.output.slice(0, 20_000) + "\n... [truncated]" : result.output,
67
+ untrusted: true,
68
+ };
69
+ }
70
+ export async function mcpStatus(runtime) {
71
+ const configs = (runtime.config.mcpServers ?? {});
72
+ const target = (config) => config.url ?? config.command ?? "(unknown)";
73
+ const manager = runtime.getMcp();
74
+ if (!manager) {
75
+ return Object.entries(configs).map(([name, config]) => ({ name, command: target(config), connected: false, tools: [], serverInfo: {}, capabilities: {}, resourceCount: 0, promptCount: 0 }));
76
+ }
77
+ await connectMcp(runtime);
78
+ const states = manager.serverStates();
79
+ const stateMap = new Map(states.map((state) => [state.name, state]));
80
+ const byServer = new Map(manager.listToolsByServer().map((server) => [server.server, server.tools]));
81
+ return Object.entries(configs).map(([name, config]) => {
82
+ const state = stateMap.get(name);
83
+ return {
84
+ name,
85
+ command: target(config),
86
+ connected: state?.state === "ready",
87
+ tools: byServer.get(name) ?? [],
88
+ serverInfo: state?.serverInfo ?? {},
89
+ capabilities: state?.capabilities ?? {},
90
+ resourceCount: state?.resourceCount ?? 0,
91
+ promptCount: state?.promptCount ?? 0,
92
+ };
93
+ });
94
+ }
95
+ export async function callMcpTool(runtime, fullName, inputArgs) {
96
+ await connectMcp(runtime);
97
+ const manager = runtime.getMcp();
98
+ const registration = manager?.getTool(fullName);
99
+ if (!registration)
100
+ return { ok: false, output: `ERROR: unknown MCP tool "${fullName}" — run /mcp to list servers and tools.` };
101
+ const trust = runtime.config.mcpServers[registration.serverName]?.trust;
102
+ const decision = runtime.permissions.classifyMcpTool(fullName, trust) ??
103
+ (runtime.config.permissionMode === "readonly" ? { decision: "deny", reason: "readonly mode" } :
104
+ runtime.config.permissionMode === "ask" ? { decision: "ask", reason: "requires user confirmation" } :
105
+ { decision: "allow", reason: "auto mode" });
106
+ if (decision.decision === "deny")
107
+ return { ok: false, output: `ERROR: MCP tool denied (${decision.reason}).` };
108
+ if (decision.decision === "ask" && !runtime.config.yes && !(await runtime.confirmMcpTool(fullName))) {
109
+ return { ok: false, output: `ERROR: user denied permission to call MCP tool: ${fullName}` };
110
+ }
111
+ let args = inputArgs;
112
+ if ("__text" in args && typeof args.__text === "string") {
113
+ const properties = (registration.def.inputSchema?.properties ?? {});
114
+ const required = (registration.def.inputSchema?.required ?? []);
115
+ const target = required.find((key) => properties[key]?.type === "string") ?? Object.keys(properties).find((key) => properties[key]?.type === "string");
116
+ if (!target)
117
+ return { ok: false, output: `ERROR: "${fullName}" has no string parameter — pass JSON args instead.` };
118
+ args = { [target]: args.__text };
119
+ }
120
+ try {
121
+ return await registration.conn.callTool(registration.toolName, args);
122
+ }
123
+ catch (err) {
124
+ return { ok: false, output: `ERROR: ${err.message}` };
125
+ }
126
+ }
127
+ export async function confirmMcpTool(runtime, fullName) {
128
+ return runtime.permissions.confirmMcpTool(fullName, () => {
129
+ try {
130
+ saveApproval(runtime.config.workdir, { action: fullName, resource: "*", effect: "allow" });
131
+ }
132
+ catch {
133
+ // Persistence failure must not block the run.
134
+ }
135
+ });
136
+ }
@@ -0,0 +1,83 @@
1
+ import { discoverAgents } from "../context/agents.js";
2
+ import { buildAgentGuideBlock } from "../context/agents-guide.js";
3
+ import { buildRepoMap } from "../context/repo-map.js";
4
+ import { buildSystemPrompt, buildEnvInfo, buildSkillsBlock, buildAgentsBlock, buildMcpBlock } from "../context/prompt-builder.js";
5
+ import { discoverSkills, findSkill } from "../context/skills.js";
6
+ import { formatWorkingMemory, memoryIsMeaningful } from "../context/memory.js";
7
+ /** Compaction prompt fragment that preserves the working-memory ledger. */
8
+ export function memoryBlock(memory, taskNote) {
9
+ if (!memoryIsMeaningful(memory))
10
+ return { instruction: "", suffix: "" };
11
+ const ledger = formatWorkingMemory(memory);
12
+ return {
13
+ instruction: `\n\nWorking memory ledger accumulated so far (keep ALL of it in the summary):\n${ledger}`,
14
+ suffix: `\n\n${ledger}${taskNote ?? ""}`,
15
+ };
16
+ }
17
+ /** Build the first system/user message batch without changing prompt composition or order. */
18
+ export function buildRunMessages(input) {
19
+ const { config, primaryDef, skills, buildCmd, task, pendingContext } = input;
20
+ const skillsBlock = buildSkillsBlock(skills);
21
+ const agentsGuideBlock = buildAgentGuideBlock(config.workdir, task);
22
+ const repoMapBlock = config.repoMap === false ? "" : `\n\n${buildRepoMap(config.workdir)}`;
23
+ const baseOverride = primaryDef?.prompt?.trim() ? primaryDef.prompt : undefined;
24
+ return [
25
+ {
26
+ role: "system",
27
+ content: buildSystemPrompt({
28
+ workdir: config.workdir,
29
+ buildCmd,
30
+ envInfo: buildEnvInfo(config.workdir),
31
+ skillsBlock,
32
+ mcpBlock: buildMcpBlock(config),
33
+ agentsGuideBlock,
34
+ repoMapBlock,
35
+ base: baseOverride,
36
+ promptVersion: config.promptVersion,
37
+ }) + buildAgentsBlock(discoverAgents(config.workdir), config.allowSubagents !== false),
38
+ },
39
+ ...pendingContext.map((content) => ({ role: "user", content })),
40
+ { role: "user", content: task },
41
+ ];
42
+ }
43
+ /** Project/global skills plus the one-shot context staged by `/skill`. */
44
+ export class PromptContext {
45
+ workdir;
46
+ skills;
47
+ pendingContext = [];
48
+ pendingAllowedTools = null;
49
+ constructor(workdir) {
50
+ this.workdir = workdir;
51
+ this.skills = discoverSkills(workdir);
52
+ }
53
+ getSkills() {
54
+ return this.skills;
55
+ }
56
+ reloadSkills() {
57
+ this.skills = discoverSkills(this.workdir);
58
+ }
59
+ skillCount() {
60
+ return this.skills.length;
61
+ }
62
+ stageSkill(name) {
63
+ const skill = findSkill(this.workdir, name);
64
+ if (!skill) {
65
+ const names = this.skills.map((item) => item.name).join(", ");
66
+ return { ok: false, message: `skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered (try /skill)"}` };
67
+ }
68
+ this.pendingContext.push(`[User loaded skill "${skill.name}" — follow these instructions for the upcoming task]\n\n${skill.body}`);
69
+ this.pendingAllowedTools = skill.allowedTools?.length ? skill.allowedTools : null;
70
+ return { ok: true, message: `skill "${skill.name}" staged — it will apply to your next task` };
71
+ }
72
+ pendingTools() {
73
+ return this.pendingAllowedTools;
74
+ }
75
+ clearPendingTools() {
76
+ this.pendingAllowedTools = null;
77
+ }
78
+ takePendingContext() {
79
+ const pending = this.pendingContext;
80
+ this.pendingContext = [];
81
+ return pending;
82
+ }
83
+ }
@@ -0,0 +1,318 @@
1
+ import { trimHistory, summariseOutput, MAX_HISTORY_TOKENS, historyTokenBudget } from "../context/context.js";
2
+ import { CHAT_DONE_MARKER } from "./guardrails.js";
3
+ import { parseDiagnostics, formatDiagnostics, classifyError } from "../workspace/diagnostics.js";
4
+ import { shouldCompact, estimateTokens, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "../context/compaction.js";
5
+ import { redactSecrets } from "../llm/llm.js";
6
+ import { memoryIsMeaningful, formatWorkingMemory, scanDecisions } from "../context/memory.js";
7
+ import { READONLY_TOOLS } from "./tool-executor.js";
8
+ /** The shared autonomous loop; it intentionally retains the existing event and persistence ordering. */
9
+ export async function runAgentLoop(runtime, startSteps, buildCmd, initialBuild = null) {
10
+ let steps = startSteps;
11
+ let lastBuild = initialBuild;
12
+ // G1: guardrail tracker state (textOnlyStreak, sigRepeat, toolError*,
13
+ // lowTokenStreak, wrapUpInjected) sống trong GuardrailEngine — reset mỗi run.
14
+ runtime.guardrails.reset();
15
+ // H-10 compaction config (false = disabled → mechanical trim only).
16
+ // I-02: contextWindow từ model catalog (lookup sync, cache/fallback) thay
17
+ // vì default 128k cứng — compaction trigger chính xác theo model.
18
+ const compactionCfg = runtime.config.compaction === false ? null : runtime.compactionSettings();
19
+ // Compaction-aware trim: mechanical trim scales with the model window and
20
+ // starts AFTER compaction (95% vs 80% trigger). Disabled compaction keeps
21
+ // the legacy conservative 90k cap.
22
+ const trimBudget = compactionCfg
23
+ ? historyTokenBudget(compactionCfg.contextWindow)
24
+ : MAX_HISTORY_TOKENS;
25
+ // When a run ends early (guardrail finish/build verification) compaction is
26
+ // skipped, so never leave a near-full window for the next turn.
27
+ const safeTrimBudget = Math.min(trimBudget, MAX_HISTORY_TOKENS);
28
+ while (steps < runtime.config.maxSteps && !runtime.aborted) {
29
+ // ── Safe step boundary: deliver steered user input (H-08) ────────────
30
+ const steered = runtime.drainSteerInbox();
31
+ if (steered.length > 0) {
32
+ for (const s of steered) {
33
+ runtime.messages.push({ role: "user", content: s });
34
+ runtime.events.append({ type: "message.added", message: { role: "user", content: s } });
35
+ }
36
+ // One allowance reset for the whole steering batch (opencode behavior)
37
+ // — engine exposes a hook so the runLoop doesn't touch tracker state.
38
+ runtime.guardrails.notifySteered();
39
+ runtime.emit({ type: "steer_delivered", count: steered.length });
40
+ runtime.emit({ type: "queue", ...runtime.steerCounts() });
41
+ runtime.reporter.info(`steer: +${steered.length} user message(s) delivered`);
42
+ }
43
+ // v2 (3.4): fold the ledger into the conversation every 6 steps.
44
+ if (steps > 0 && steps % 6 === 0 && memoryIsMeaningful(runtime.memory)) {
45
+ runtime.messages.push({ role: "user", content: formatWorkingMemory(runtime.memory) });
46
+ }
47
+ steps++;
48
+ runtime.reporter.step(steps, runtime.config.maxSteps, "thinking...");
49
+ runtime.events.claimExecution(steps);
50
+ runtime.emit({ type: "step", step: steps, maxSteps: runtime.config.maxSteps });
51
+ // Metrics: thời gian phần LLM của bước này (điều chỉnh chất lượng +
52
+ // so sánh provider; hiển thị trong `scoder cost`).
53
+ const stepStart = Date.now();
54
+ const { message, usage, incomplete } = await runtime.chatWithStream().catch((err) => {
55
+ // SIGINT during the LLM call — stop gracefully instead of crashing
56
+ if (runtime.aborted) {
57
+ return {
58
+ message: { role: "assistant", content: null, tool_calls: undefined },
59
+ usage: undefined,
60
+ incomplete: undefined,
61
+ };
62
+ }
63
+ throw err;
64
+ });
65
+ if (runtime.aborted)
66
+ break;
67
+ runtime.recordLlmUsage(usage);
68
+ runtime.emitContextEvent();
69
+ runtime.logger.log({ type: "step", step: steps, durationMs: Date.now() - stepStart, toolCalls: message.tool_calls?.length ?? 0 });
70
+ // H-09: after an interrupted stream, nudge the model to continue rather
71
+ // than restart (synthetic continuation instruction, opencode pattern).
72
+ if (incomplete) {
73
+ runtime.messages.push({
74
+ role: "user",
75
+ content: "[system] The previous response was cut off mid-stream. The tool call(s) above were received in full — continue exactly where you left off; do not repeat completed work.",
76
+ });
77
+ runtime.events.append({ type: "retry.scheduled", reason: "incomplete-stream continuation" });
78
+ }
79
+ // ── Chuẩn bị context cho Guardrail Engine ─────────────────────────────
80
+ // Chat marker được làm sạch ở đây để rule nhận text đã chuẩn hoá.
81
+ const hasToolCalls = !!message.tool_calls && message.tool_calls.length > 0;
82
+ let text = message.content ?? "";
83
+ let chatDone = false;
84
+ if (!hasToolCalls && text.includes(CHAT_DONE_MARKER)) {
85
+ chatDone = true;
86
+ text = text.split(CHAT_DONE_MARKER)[0].trim();
87
+ }
88
+ if (!hasToolCalls) {
89
+ runtime.messages.push({ role: "assistant", content: text });
90
+ runtime.events.append({ type: "message.added", message: { role: "assistant", content: text } });
91
+ runtime.reporter.llm("agent", text);
92
+ scanDecisions(runtime.memory, text);
93
+ }
94
+ else {
95
+ runtime.messages.push({
96
+ role: "assistant",
97
+ content: message.content ?? null,
98
+ tool_calls: message.tool_calls,
99
+ });
100
+ runtime.events.append({ type: "message.added", message: runtime.messages[runtime.messages.length - 1] });
101
+ if (typeof message.content === "string")
102
+ scanDecisions(runtime.memory, message.content);
103
+ }
104
+ // v2 (3.3): soft guardrail — editing without a plan gets a reminder.
105
+ const batchMayWriteEarly = (message.tool_calls ?? []).some((tc) => tc.name === "write_file" || tc.name === "edit_file" || tc.name === "apply_patch");
106
+ if (batchMayWriteEarly && runtime.plan.length === 0 && !runtime.planNudged) {
107
+ runtime.planNudged = true;
108
+ runtime.messages.push({
109
+ role: "user",
110
+ content: "[system] You modified files without a plan. Call write_todos now with the full remaining plan " +
111
+ "(steps with verify + acceptance), then continue. One step in_progress at a time.",
112
+ });
113
+ }
114
+ // Execute each tool call (validate args before running)
115
+ const stepToolResults = new Map();
116
+ // H-11: checkpoint before any batch that may write files.
117
+ const batchMayWrite = (message.tool_calls ?? []).some((tc) => tc.name === "write_file" || tc.name === "edit_file" || tc.name === "apply_patch");
118
+ if (batchMayWrite)
119
+ runtime.snapshots.capture(`step ${steps}: ${(message.tool_calls ?? []).map((t) => t.name).join(",")}`);
120
+ // I-09: parallelize read-only tool calls (concurrency cap 4); mutating
121
+ // calls (and any batch containing an ask-gated call) run sequentially.
122
+ const calls = message.tool_calls ?? [];
123
+ const needsAsk = calls.some((tc) => {
124
+ if (tc.name !== "run_command")
125
+ return false;
126
+ try {
127
+ const a = JSON.parse(tc.arguments || "{}");
128
+ if (typeof a.command !== "string")
129
+ return false;
130
+ const d = runtime.permissions.classifyShell(a.command);
131
+ return d.decision === "ask" || d.decision === "deny";
132
+ }
133
+ catch {
134
+ return true;
135
+ }
136
+ });
137
+ if (calls.length > 1 && !needsAsk) {
138
+ const readOnly = calls.every((tc) => READONLY_TOOLS.has(tc.name));
139
+ if (readOnly) {
140
+ const executor = runtime.toolExecutor();
141
+ const results = await executor.parallelMap(calls, (tc) => executor.executeToolCall(tc, steps, stepToolResults), Math.max(1, runtime.config.limits?.toolConcurrency ?? 4));
142
+ for (const r of results) {
143
+ if (r.post)
144
+ r.post();
145
+ }
146
+ }
147
+ else {
148
+ for (const tc of calls) {
149
+ if (runtime.aborted)
150
+ break;
151
+ const r = await runtime.toolExecutor().executeToolCall(tc, steps, stepToolResults);
152
+ if (r.post)
153
+ r.post();
154
+ }
155
+ }
156
+ }
157
+ else {
158
+ for (const tc of calls) {
159
+ if (runtime.aborted)
160
+ break;
161
+ const r = await runtime.toolExecutor().executeToolCall(tc, steps, stepToolResults);
162
+ if (r.post)
163
+ r.post();
164
+ }
165
+ }
166
+ // ── Guardrail Engine — đánh giá toàn bộ rule sau khi tools chạy xong ──
167
+ // Thông tin build stop condition: có tool call nào chạy ĐÚNG buildCmd?
168
+ // Match theo arguments đã parse (đúng hơn substring: "npm run build && rm -rf /"
169
+ // vẫn tính là build call, nhưng lệnh echo chứa chuỗi buildCmd thì không).
170
+ const buildCall = hasToolCalls
171
+ ? (message.tool_calls ?? []).find((tc) => tc.name === "run_command" &&
172
+ (() => {
173
+ try {
174
+ const a = JSON.parse(tc.arguments || "{}");
175
+ return typeof a.command === "string" && a.command.includes(buildCmd);
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ })())
181
+ : undefined;
182
+ let buildPassedThisStep = false;
183
+ if (buildCall) {
184
+ const r = stepToolResults.get(buildCall.id);
185
+ if (r && r.ok)
186
+ buildPassedThisStep = true;
187
+ }
188
+ const decisions = await runtime.guardrails.evaluate({
189
+ step: steps,
190
+ maxSteps: runtime.config.maxSteps,
191
+ text,
192
+ message,
193
+ hasToolCalls,
194
+ toolResults: stepToolResults,
195
+ buildPassedThisStep,
196
+ buildCallPresent: !!buildCall,
197
+ filesModified: runtime.filesModified,
198
+ chatDone,
199
+ outputTokensThisStep: usage?.completion_tokens,
200
+ totalTokensIn: runtime.usageTotals.inputTokens, // G6 budget rule
201
+ totalTokensOut: runtime.usageTotals.outputTokens,
202
+ });
203
+ // Áp dụng decision: finish đầu tiên thắng; inject gom lại đẩy một lần.
204
+ const reminders = [];
205
+ let doTrim = false;
206
+ for (const d of decisions) {
207
+ const a = d.action;
208
+ if (a.kind === "inject") {
209
+ if (d.rule === "wrap-up")
210
+ runtime.guardrails.markWrapUpInjected(); // chỉ inject một lần / run
211
+ reminders.push(a.reminder);
212
+ continue;
213
+ }
214
+ if (a.kind === "trim") {
215
+ doTrim = true;
216
+ continue;
217
+ }
218
+ if (a.kind === "verify-build") {
219
+ // Xác minh độc lập với lời của model (async — loop tự chạy verify).
220
+ lastBuild = await runtime.verifyAll(buildCmd);
221
+ if (lastBuild.success) {
222
+ // v2: self-review the diff before calling it done.
223
+ const reviewNote = await runtime.selfReviewGate();
224
+ if (reviewNote) {
225
+ reminders.push(`[self-review] blocking findings — fix these, then verify again:\n${reviewNote}`);
226
+ continue;
227
+ }
228
+ if (doTrim)
229
+ runtime.messages = trimHistory(runtime.messages, safeTrimBudget);
230
+ return runtime.finish(true, steps, buildCmd, a.successSummary);
231
+ }
232
+ // Structured diagnostics + error class instead of a raw log tail.
233
+ const diags = parseDiagnostics(lastBuild.output);
234
+ const errClass = classifyError(lastBuild.output);
235
+ const signature = runtime.failureSignature(lastBuild.output);
236
+ runtime.verifyStrikes = signature === runtime.lastVerifySignature ? runtime.verifyStrikes + 1 : 1;
237
+ runtime.lastVerifySignature = signature;
238
+ const failBlock = diags.length > 0 ? formatDiagnostics(diags) : summariseOutput(lastBuild.output);
239
+ reminders.push(`${a.failReminder}\n${failBlock}\nError class: ${errClass.kind} — ${errClass.strategy}\nFix and continue.`);
240
+ if (runtime.verifyStrikes >= 4) {
241
+ for (const r of reminders)
242
+ runtime.messages.push({ role: "user", content: r });
243
+ if (doTrim)
244
+ runtime.messages = trimHistory(runtime.messages, safeTrimBudget);
245
+ return runtime.finish(false, steps, buildCmd, `Verification failed the same way ${runtime.verifyStrikes} times — stopping. Last error class: ${errClass.kind}.`);
246
+ }
247
+ if (runtime.verifyStrikes >= 2) {
248
+ reminders.push("<system-reminder>The SAME verification failed repeatedly. Stop retrying the identical approach — change strategy fundamentally, or report the blocker in your final message.</system-reminder>");
249
+ }
250
+ continue;
251
+ }
252
+ if (a.kind === "finish") {
253
+ // Giữ nguyên thứ tự tin nhắn như bản inline cũ: reminder (wrap-up)
254
+ // đã đẩy trước khi kết thúc.
255
+ if (a.success && runtime.filesModified && !a.chatOnly && !runtime.selfReviewDone && runtime.config.selfReview !== false) {
256
+ const reviewNote = await runtime.selfReviewGate();
257
+ if (reviewNote) {
258
+ reminders.push(`[self-review] blocking findings — fix these, then verify again:\n${reviewNote}`);
259
+ continue;
260
+ }
261
+ }
262
+ for (const r of reminders)
263
+ runtime.messages.push({ role: "user", content: r });
264
+ if (doTrim)
265
+ runtime.messages = trimHistory(runtime.messages, safeTrimBudget);
266
+ return runtime.finish(a.success, steps, buildCmd, a.summary, a.chatOnly ?? false);
267
+ }
268
+ }
269
+ for (const r of reminders)
270
+ runtime.messages.push({ role: "user", content: r });
271
+ // H-10: LLM compaction when history approaches the context window.
272
+ // Runs BEFORE mechanical trim: trim drops whole turns, which could push
273
+ // the history below the trigger before compaction ever sees it.
274
+ if (compactionCfg && shouldCompact(runtime.messages, compactionCfg)) {
275
+ try {
276
+ runtime.reporter.info("compaction: summarizing older history…");
277
+ const tokensBefore = estimateTokens(runtime.messages);
278
+ const summaryInput = extractSummaryInput(runtime.currentTask, runtime.messages);
279
+ const mem = runtime.memoryBlock();
280
+ const summaryRes = await runtime.client.chat([
281
+ ...runtime.messages,
282
+ { role: "user", content: `${summaryInput}\n\n${COMPACTION_INSTRUCTION}${mem.instruction}` },
283
+ ], [], // no tools for the summarizer call
284
+ { signal: runtime.runSignal.signal });
285
+ runtime.recordLlmUsage(summaryRes.usage);
286
+ const summary = redactSecrets(summaryRes.message.content ?? "History summarized.") + mem.suffix;
287
+ runtime.messages = applyCompaction(runtime.messages, summary, compactionCfg);
288
+ const tokensAfter = estimateTokens(runtime.messages);
289
+ runtime.events.append({
290
+ type: "compaction.ran",
291
+ tokensBefore,
292
+ tokensAfter,
293
+ summary: summary.slice(0, 2000),
294
+ });
295
+ runtime.snapshotMemory("compaction");
296
+ runtime.reporter.info(`compaction: done (${tokensBefore} → ${tokensAfter} tokens, history now ${runtime.messages.length} messages)`);
297
+ runtime.emit({ type: "compaction", tokensBefore, tokensAfter, manual: false });
298
+ runtime.emitContextEvent(true);
299
+ }
300
+ catch (err) {
301
+ // Compaction is best-effort — mechanical trim stays as fallback.
302
+ runtime.reporter.warn(`compaction failed (${err.message.slice(0, 100)}) — using mechanical trim`);
303
+ // Fallback uses the legacy conservative cap: the next LLM call must
304
+ // not leave a near-full window if summarization failed.
305
+ runtime.messages = trimHistory(runtime.messages, safeTrimBudget);
306
+ }
307
+ }
308
+ if (doTrim)
309
+ runtime.messages = trimHistory(runtime.messages, trimBudget);
310
+ // Persist session state after every step (enables resume after crash/SIGINT)
311
+ runtime.saveSession(steps, buildCmd, lastBuild);
312
+ runtime.events.releaseExecution(steps);
313
+ }
314
+ // Max steps reached or aborted
315
+ const reason = runtime.aborted ? "interrupted by user (SIGINT)" : `reached max steps (${runtime.config.maxSteps})`;
316
+ runtime.saveSession(steps, buildCmd, lastBuild);
317
+ return runtime.finish(false, steps, buildCmd, `Agent stopped: ${reason}`);
318
+ }