diff --git a/src/resources/extensions/sf/auto/loop.js b/src/resources/extensions/sf/auto/loop.js index 63b5004c2..8befae5a8 100644 --- a/src/resources/extensions/sf/auto/loop.js +++ b/src/resources/extensions/sf/auto/loop.js @@ -237,19 +237,19 @@ async function drainSleeptimeQueue(basePath) { } if (!pending || pending.length === 0) return; const { AgentSwarm } = await import("../uok/agent-swarm.js"); + const { runAgentTurn } = await import("../uok/agent-runner.js"); for (const job of pending) { try { const swarm = new AgentSwarm(basePath); const memAgent = swarm.getByRole("coordinator")[0]; if (memAgent) { swarm.send(job.conversation_agent, job.memory_agent, job.content); - const received = memAgent.receive(true); - const last = received[received.length - 1]; - const result = last?.body - ? typeof last.body === "string" - ? last.body - : JSON.stringify(last.body) - : ""; + const result = await runAgentTurn(memAgent, { timeoutMs: 60_000 }); + const output = result.response + ? typeof result.response === "string" + ? result.response + : JSON.stringify(result.response) + : result.error ?? ""; db.prepare( `UPDATE sleeptime_consolidation_queue SET status = 'done', processed_at = :ts, result = :result @@ -257,7 +257,7 @@ async function drainSleeptimeQueue(basePath) { ).run({ ":id": job.id, ":ts": new Date().toISOString(), - ":result": result, + ":result": output, }); } else { db.prepare( diff --git a/src/resources/extensions/sf/error-classifier.js b/src/resources/extensions/sf/error-classifier.js index 304b337c2..314cdaa55 100644 --- a/src/resources/extensions/sf/error-classifier.js +++ b/src/resources/extensions/sf/error-classifier.js @@ -37,7 +37,7 @@ const UNSUPPORTED_MODEL_SCOPE_RE = /\b(?:account|plan|tier|subscription)\b/i; // OpenRouter affordability-style quota errors should be treated as transient // so core retry logic can lower maxTokens and continue in-session. const AFFORDABILITY_RE = - /requires more credits|can only afford|insufficient credits|not enough credits|fewer max_tokens/i; + /requires more credits|can only afford|insufficient (?:credits|balance)|not enough credits|credit balance|fewer max_tokens/i; const NETWORK_RE = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns/i; const SERVER_RE = diff --git a/src/resources/extensions/sf/preferences-models.js b/src/resources/extensions/sf/preferences-models.js index eaff35219..7e9ba782d 100644 --- a/src/resources/extensions/sf/preferences-models.js +++ b/src/resources/extensions/sf/preferences-models.js @@ -10,9 +10,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { getModels, getProviders } from "@singularity-forge/ai"; import { selectByBenchmarks } from "./benchmark-selector.js"; -import { sfHome } from "./sf-home.js"; - import { defaultRoutingConfig, MODEL_CAPABILITY_TIER } from "./model-router.js"; +import { sfHome } from "./sf-home.js"; import { DEFAULT_RUNAWAY_CHANGED_FILES_WARNING, DEFAULT_RUNAWAY_DIAGNOSTIC_TURNS, @@ -380,11 +379,13 @@ export function resolveModelWithFallbacksForUnit(unitType, options = {}) { phaseConfig = m.completion; break; case "reassess-roadmap": - case "rewrite-docs": case "gate-evaluate": case "validate-milestone": phaseConfig = m.validation ?? m.planning; break; + case "rewrite-docs": + phaseConfig = m.validation ?? m.execution ?? m.planning; + break; default: // Subagent unit types (e.g., "subagent", "subagent/scout") if (unitType === "subagent" || unitType.startsWith("subagent/")) { diff --git a/src/resources/extensions/sf/preferences.js b/src/resources/extensions/sf/preferences.js index 109fcf731..5325cdd90 100644 --- a/src/resources/extensions/sf/preferences.js +++ b/src/resources/extensions/sf/preferences.js @@ -14,7 +14,6 @@ import { dirname, join, resolve } from "node:path"; import { normalizeStringArray } from "@singularity-forge/coding-agent"; import { parse as parseYaml } from "yaml"; import { sfRoot } from "./paths.js"; -import { sfHome } from "./sf-home.js"; import { _initPrefsLoader, resolveProfileDefaults as _resolveProfileDefaults, @@ -22,6 +21,7 @@ import { import { upgradePreferencesFileIfDrifted } from "./preferences-template-upgrade.js"; import { formatSkillRef, MODE_DEFAULTS } from "./preferences-types.js"; import { validatePreferences } from "./preferences-validation.js"; +import { sfHome } from "./sf-home.js"; import { logWarning } from "./workflow-logger.js"; // ─── Re-exports: types ────────────────────────────────────────────────────── @@ -87,6 +87,9 @@ export function getSfAgentSettingsPath() { function globalPreferencesYamlPath() { return join(sfHome(), "preferences.yaml"); } +function legacyGlobalPreferencesMarkdownPath() { + return join(sfHome(), "preferences.md"); +} /** * Resolve the "project root" for preferences. When SF is running inside a * git worktree (e.g. `.sf/worktrees/M003/`), project-level prefs should @@ -147,7 +150,12 @@ export function getProjectSFPreferencesPath() { * Load global SF preferences from preferences.yaml. */ export function loadGlobalSFPreferences() { - return loadPreferencesFile(globalPreferencesYamlPath(), "global"); + return ( + loadPreferencesFile(globalPreferencesYamlPath(), "global") ?? + loadPreferencesFile(legacyGlobalPreferencesMarkdownPath(), "global", { + legacyMarkdown: true, + }) + ); } /** * Load project-level SF preferences from preferences.yaml. @@ -205,17 +213,21 @@ export function loadEffectiveSFPreferences() { } return result; } -function loadPreferencesFile(path, scope) { +function loadPreferencesFile(path, scope, options = {}) { if (!existsSync(path)) return null; const raw = readFileSync(path, "utf-8"); - const preferences = parsePreferencesYaml(raw); + const preferences = options.legacyMarkdown + ? parsePreferencesMarkdown(raw) + : parsePreferencesYaml(raw); if (!preferences) return null; const validation = validatePreferences(preferences); // Self-align: if the file's recorded sf version drifted from current, // silently re-render the frontmatter so subsequent reads match. No // human-facing warning — sf keeps its own files in sync. Body content // (anything after the frontmatter) is preserved verbatim. - const aligned = upgradePreferencesFileIfDrifted(path, validation.preferences); + const aligned = options.legacyMarkdown + ? validation.preferences + : upgradePreferencesFileIfDrifted(path, validation.preferences); const allWarnings = [...validation.warnings, ...validation.errors]; return { path, diff --git a/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs b/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs index f625023c8..209132a3b 100644 --- a/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs +++ b/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs @@ -43,6 +43,7 @@ vi.mock("../auto-prompts.js", () => ({ import { closeDatabase, + insertAssessment, insertMilestone, insertSlice, openDatabase, @@ -152,4 +153,63 @@ describe("resolveDispatch canonical milestone plan", () => { cleanup(base); } }); + + test("completing_milestone_when_validation_needs_attention_without_plan_dispatches_remediation", async () => { + const base = makeTempDir("sf-dispatch-validation-attention-"); + try { + mkdirSync(join(base, ".sf"), { recursive: true }); + openDatabase(join(base, ".sf", "sf.db")); + insertMilestone({ + id: "M900", + title: "Validation self healing", + status: "active", + }); + const validationContent = [ + "---", + "verdict: needs-attention", + "remediation_round: 0", + "---", + "", + "# Milestone Validation: M900", + "", + "## Verdict Rationale", + "The validation looks stale: it says only S01 is complete even though DB-backed slice state may have moved on.", + "Address or explicitly defer the finding and rerun validation.", + "", + ].join("\n"); + insertAssessment({ + path: ".sf/milestones/M900/M900-VALIDATION.md", + milestoneId: "M900", + scope: "milestone-validation", + status: "needs-attention", + fullContent: validationContent, + }); + + const result = await resolveDispatch({ + state: { + phase: "completing-milestone", + }, + mid: "M900", + midTitle: "Validation self healing", + basePath: base, + prefs: { phases: {} }, + session: {}, + pipelineVariant: "standard", + }); + + expect(result).toMatchObject({ + action: "dispatch", + unitType: "rewrite-docs", + unitId: "M900/validation-attention", + }); + expect(result.reason).toBeUndefined(); + expect(result.prompt).toContain( + "No structured remediation section was present", + ); + expect(result.prompt).toContain("ready for revalidation"); + } finally { + closeDatabase(); + cleanup(base); + } + }); }); diff --git a/src/resources/extensions/sf/tests/error-classifier.test.mjs b/src/resources/extensions/sf/tests/error-classifier.test.mjs new file mode 100644 index 000000000..81bfa8bc1 --- /dev/null +++ b/src/resources/extensions/sf/tests/error-classifier.test.mjs @@ -0,0 +1,20 @@ +/** + * error-classifier.test.mjs — provider failure routing contracts. + * + * Purpose: keep provider-account balance failures in the autonomous fallback + * path so one drained route does not pause a unit that has usable fallbacks. + */ +import { describe, expect, test } from "vitest"; + +import { classifyError, isTransient } from "../error-classifier.js"; + +describe("classifyError", () => { + test("opencode_insufficient_balance_routes_to_fallback", () => { + const result = classifyError( + "401 Insufficient balance. Manage your billing here: https://opencode.ai/workspace/example/billing", + ); + + expect(result.kind).toBe("rate-limit"); + expect(isTransient(result)).toBe(true); + }); +}); diff --git a/src/resources/extensions/sf/tests/preferences-models.test.mjs b/src/resources/extensions/sf/tests/preferences-models.test.mjs index 2cee5e096..63e36c837 100644 --- a/src/resources/extensions/sf/tests/preferences-models.test.mjs +++ b/src/resources/extensions/sf/tests/preferences-models.test.mjs @@ -39,10 +39,16 @@ function makePreferencesProject(projectPreferences, projectSettings) { ); } process.env.HOME = home; + process.env.SF_HOME = join(home, ".sf"); process.chdir(project); return project; } +function writeLegacyGlobalPreferences(project, content) { + const sfHome = join(project, "..", "home", ".sf"); + writeFileSync(join(sfHome, "preferences.md"), content, "utf-8"); +} + describe("preferences model resolution", () => { test("resolveModelWithFallbacksForUnit_when_google_env_auth_is_default_off_skips_google_auto_benchmark_candidates", () => { makePreferencesProject( @@ -56,4 +62,49 @@ describe("preferences model resolution", () => { assert.equal(result, undefined); }); + + test("resolveModelWithFallbacksForUnit_when_global_yaml_missing_reads_legacy_global_preferences_md", () => { + const project = makePreferencesProject( + ["version: 1", "models: {}", ""].join("\n"), + ); + writeLegacyGlobalPreferences( + project, + [ + "---", + "version: 1", + "models:", + " execution: kimi-coding/kimi-k2.6", + "---", + "", + "# Global SF Preferences", + "", + ].join("\n"), + ); + + const result = resolveModelWithFallbacksForUnit("execute-task"); + + assert.deepEqual(result, { + primary: "kimi-coding/kimi-k2.6", + fallbacks: [], + }); + }); + + test("resolveModelWithFallbacksForUnit_when_rewrite_docs_has_no_validation_model_uses_execution_model", () => { + makePreferencesProject( + [ + "version: 1", + "models:", + " planning: minimax/MiniMax-M2.7", + " execution: kimi-coding/kimi-k2.6", + "", + ].join("\n"), + ); + + const result = resolveModelWithFallbacksForUnit("rewrite-docs"); + + assert.deepEqual(result, { + primary: "kimi-coding/kimi-k2.6", + fallbacks: [], + }); + }); }); diff --git a/src/resources/extensions/sf/uok/agent-runner.js b/src/resources/extensions/sf/uok/agent-runner.js new file mode 100644 index 000000000..c6a108b2c --- /dev/null +++ b/src/resources/extensions/sf/uok/agent-runner.js @@ -0,0 +1,239 @@ +/** + * agent-runner.js — LLM execution runner for PersistentAgent swarm agents. + * + * Purpose: bridge the gap between PersistentAgent (passive inbox/memory store) + * and actual LLM execution. Reads pending messages, assembles prompts, dispatches + * to LLM via `sf headless --print`, writes replies back to the MessageBus, and + * manages context window. + * + * Integration: imported lazily by `AgentSwarm.run()` when `opts.enableLLM` is true, + * and directly by `auto/loop.js` for sleeptime consolidation. Keeps PersistentAgent + * itself free of child_process spawning logic. + * + * Consumer: AgentSwarm.run() orchestrator and autonomous dispatch paths. + */ + +import { spawn } from "node:child_process"; + +const DEFAULT_MAX_CONTEXT_TURNS = 10; +const DEFAULT_MAX_TURNS_PER_RUN = 5; +const DEFAULT_RUNNER_TIMEOUT_MS = 120_000; +const DEFAULT_POLL_INTERVAL_MS = 1_000; + +/** + * Assemble a prompt from core memory blocks + recent inbox messages. + */ +function buildAgentPrompt(agent, messages) { + const blocks = agent.getContextBlocks(); + const lines = []; + + if (blocks) { + lines.push(blocks, ""); + } + + lines.push("## Instructions", ""); + lines.push( + "You are a persistent agent in a multi-agent swarm. Process the incoming messages and produce a response.", + ); + lines.push("Be concise. Use tools when needed. Return structured output."); + lines.push(""); + + if (messages.length > 0) { + lines.push("## Incoming Messages", ""); + for (const msg of messages) { + const from = msg.from?.replace(/^agent:/, "") ?? "unknown"; + lines.push( + `**${from}**: ${typeof msg.body === "string" ? msg.body : JSON.stringify(msg.body)}`, + ); + } + lines.push(""); + } + + lines.push("## Your Response"); + return lines.join("\n"); +} + +/** + * Execute a prompt via sf headless --print mode. + * Returns the LLM response text. + */ +function runHeadlessPrompt( + basePath, + prompt, + timeoutMs = DEFAULT_RUNNER_TIMEOUT_MS, +) { + return new Promise((resolve, reject) => { + const sfBin = process.env.SF_BIN_PATH || process.argv[1]; + if (!sfBin) { + reject(new Error("SF_BIN_PATH not set and process.argv[1] unavailable")); + return; + } + + const args = [sfBin, "--print", prompt]; + const child = spawn(process.execPath, args, { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, SF_AUTONOMOUS: "0" }, + }); + + const chunks = []; + const errChunks = []; + let settled = false; + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + child.kill("SIGTERM"); + reject(new Error(`Agent runner timed out after ${timeoutMs}ms`)); + } + }, timeoutMs); + + child.stdout.on("data", (chunk) => chunks.push(chunk)); + child.stderr.on("data", (chunk) => errChunks.push(chunk)); + + child.on("error", (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(err); + } + }); + + child.on("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + + const stdout = Buffer.concat(chunks).toString("utf-8").trim(); + const stderr = Buffer.concat(errChunks).toString("utf-8").trim(); + + if (code !== 0) { + reject( + new Error( + `sf headless exited ${code}: ${stderr || stdout || "unknown error"}`, + ), + ); + return; + } + + resolve(stdout); + }); + }); +} + +/** + * Run a single turn for a PersistentAgent: read inbox, build prompt, call LLM, reply. + * + * @param {PersistentAgent} agent + * @param {object} [opts] + * @param {number} [opts.maxContextTurns=10] + * @param {number} [opts.timeoutMs=120000] + * @returns {Promise<{turnsProcessed: number, response: string|null}>} + */ +export async function runAgentTurn(agent, opts = {}) { + const { + maxContextTurns = DEFAULT_MAX_CONTEXT_TURNS, + timeoutMs = DEFAULT_RUNNER_TIMEOUT_MS, + } = opts; + + const messages = agent.receive(true); + if (messages.length === 0) { + return { turnsProcessed: 0, response: null }; + } + + // Mark messages as read before processing so a crash doesn't re-process them + for (const msg of messages) { + agent.markRead(msg.id); + } + + // Limit context window + const contextMessages = messages.slice(-maxContextTurns); + const prompt = buildAgentPrompt(agent, contextMessages); + + let response; + try { + response = await runHeadlessPrompt(agent._basePath, prompt, timeoutMs); + } catch (err) { + // On failure, write error back to bus so sender knows + agent._bus.send( + `agent:${agent.identity.name}`, + messages[0]?.from ?? "agent:coordinator", + { error: err.message, original: messages[0]?.body }, + { replyTo: messages[0]?.id, type: "error" }, + ); + return { turnsProcessed: 0, response: null, error: err.message }; + } + + // Reply to the most recent message (or broadcast if no clear target) + const target = messages[messages.length - 1]?.from ?? `agent:coordinator`; + const replyId = agent._bus.send( + `agent:${agent.identity.name}`, + target, + response, + { replyTo: messages[messages.length - 1]?.id, type: "response" }, + ); + + return { turnsProcessed: messages.length, response, replyId }; +} + +/** + * Continuously poll and run an agent until no more unread messages or max turns reached. + * + * @param {PersistentAgent} agent + * @param {object} [opts] + * @param {number} [opts.maxTurns=5] + * @param {number} [opts.pollIntervalMs=1000] + * @param {number} [opts.timeoutMs=120000] + * @returns {Promise<{totalTurns: number, stoppedReason: string}>} + */ +export async function runAgentLoop(agent, opts = {}) { + const { + maxTurns = DEFAULT_MAX_TURNS_PER_RUN, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + timeoutMs = DEFAULT_RUNNER_TIMEOUT_MS, + } = opts; + + let totalTurns = 0; + const startTime = Date.now(); + + while (totalTurns < maxTurns) { + if (Date.now() - startTime > timeoutMs) { + return { totalTurns, stoppedReason: "timeout" }; + } + + const result = await runAgentTurn(agent, { + timeoutMs: Math.min(timeoutMs, 60_000), + }); + if (result.turnsProcessed === 0) { + return { totalTurns, stoppedReason: "no_messages" }; + } + + totalTurns += result.turnsProcessed; + + // Brief pause between turns to avoid hammering the LLM + if (totalTurns < maxTurns) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + } + + return { totalTurns, stoppedReason: "max_turns" }; +} + +/** + * Run a swarm turn: each agent in the swarm processes its inbox once. + * + * @param {AgentSwarm} swarm + * @param {object} [opts] + * @returns {Promise>} + */ +export async function runSwarmTurn(swarm, opts = {}) { + const agents = swarm.getAll(); + const results = []; + + for (const agent of agents) { + const result = await runAgentTurn(agent, opts); + results.push({ agentName: agent.identity.name, result }); + } + + return results; +} diff --git a/src/resources/extensions/sf/uok/agent-swarm.js b/src/resources/extensions/sf/uok/agent-swarm.js index 1f42cf4dc..bfff260ef 100644 --- a/src/resources/extensions/sf/uok/agent-swarm.js +++ b/src/resources/extensions/sf/uok/agent-swarm.js @@ -413,19 +413,25 @@ export class AgentSwarm { * topology (round_robin, supervisor, dynamic, sleeptime) and return the full * turn history with termination status. * + * When `opts.enableLLM` is true, each agent turn is backed by an actual LLM + * call via the agent runner. Otherwise the loop is a pure message router + * (useful for tests and lightweight orchestration). + * * Consumer: autonomous dispatch, eval harness, and multi-agent task runners. * * @param {string} initialMessage * @param {object} [opts] * @param {string} [opts.from] - agent name to send as * @param {number} [opts.maxTurns] - override swarm default - * @param {number} [opts.timeout] - timeout in ms (informational; not enforced at socket level) - * @returns {{ turns: Array<{agent: string, message: string}>, terminated: boolean, reason: string }} + * @param {number} [opts.timeout] - per-turn timeout in ms (default 120000) + * @param {boolean} [opts.enableLLM] - run LLM-backed turns (default false) + * @returns {Promise<{ turns: Array<{agent: string, message: string}>, terminated: boolean, reason: string }>} */ - run(initialMessage, opts = {}) { + async run(initialMessage, opts = {}) { const maxTurns = opts.maxTurns ?? this._maxTurns; const fromName = opts.from ?? "external"; const terminationToken = this._terminationToken; + const enableLLM = opts.enableLLM ?? false; const turns = []; let terminated = false; @@ -436,6 +442,13 @@ export class AgentSwarm { return { turns, terminated: false, reason: "no_agents" }; } + /** @type {((agent: import("./persistent-agent.js").PersistentAgent, opts?: object) => Promise<{response?: string}>) | null} */ + let runAgentTurn = null; + if (enableLLM) { + const runner = await import("./agent-runner.js"); + runAgentTurn = runner.runAgentTurn; + } + switch (this._managerType) { case ManagerType.ROUND_ROBIN: { let message = initialMessage; @@ -443,9 +456,18 @@ export class AgentSwarm { const agent = agents[i % agents.length]; const agentName = agent.identity.name; this.send(fromName, agentName, message); - const received = agent.receive(false); - const last = received[received.length - 1]; - const reply = _bodyToString(last?.body, message); + + let reply; + if (runAgentTurn) { + const result = await runAgentTurn(agent, { + timeoutMs: opts.timeout, + }); + reply = result.response ?? message; + } else { + const received = agent.receive(false); + const last = received[received.length - 1]; + reply = _bodyToString(last?.body, message); + } turns.push({ agent: agentName, message: reply }); if (reply.includes(terminationToken)) { terminated = true; @@ -465,9 +487,17 @@ export class AgentSwarm { let message = initialMessage; for (let i = 0; i < maxTurns; i++) { - const received = supervisor.receive(true); - const last = received[received.length - 1]; - const reply = _bodyToString(last?.body, message); + let reply; + if (runAgentTurn) { + const result = await runAgentTurn(supervisor, { + timeoutMs: opts.timeout, + }); + reply = result.response ?? message; + } else { + const received = supervisor.receive(true); + const last = received[received.length - 1]; + reply = _bodyToString(last?.body, message); + } turns.push({ agent: supervisorName, message: reply }); if (reply.includes(terminationToken)) { terminated = true; @@ -481,9 +511,18 @@ export class AgentSwarm { const worker = workers[i % workers.length]; const workerName = worker.identity.name; this.send(supervisorName, workerName, reply); - const workerMessages = worker.receive(true); - const workerLast = workerMessages[workerMessages.length - 1]; - const workerReply = _bodyToString(workerLast?.body, reply); + + let workerReply; + if (runAgentTurn) { + const result = await runAgentTurn(worker, { + timeoutMs: opts.timeout, + }); + workerReply = result.response ?? reply; + } else { + const workerMessages = worker.receive(true); + const workerLast = workerMessages[workerMessages.length - 1]; + workerReply = _bodyToString(workerLast?.body, reply); + } turns.push({ agent: workerName, message: workerReply }); if (workerReply.includes(terminationToken)) { terminated = true; @@ -508,9 +547,18 @@ export class AgentSwarm { for (const agent of agents) { const agentName = agent.identity.name; this.send(fromName, agentName, message); - const received = agent.receive(false); - const last = received[received.length - 1]; - const reply = _bodyToString(last?.body, message); + + let reply; + if (runAgentTurn) { + const result = await runAgentTurn(agent, { + timeoutMs: opts.timeout, + }); + reply = result.response ?? message; + } else { + const received = agent.receive(false); + const last = received[received.length - 1]; + reply = _bodyToString(last?.body, message); + } turns.push({ agent: agentName, message: reply }); if (reply.includes(terminationToken)) { terminated = true; @@ -539,9 +587,17 @@ export class AgentSwarm { this.send(fromName, convoName, message); for (let i = 0; i < maxTurns; i++) { - const received = convoAgent.receive(true); - const last = received[received.length - 1]; - const reply = _bodyToString(last?.body, message); + let reply; + if (runAgentTurn) { + const result = await runAgentTurn(convoAgent, { + timeoutMs: opts.timeout, + }); + reply = result.response ?? message; + } else { + const received = convoAgent.receive(true); + const last = received[received.length - 1]; + reply = _bodyToString(last?.body, message); + } turns.push({ agent: convoName, message: reply }); if (reply.includes(terminationToken)) { terminated = true; diff --git a/src/resources/extensions/sf/uok/auto-dispatch.js b/src/resources/extensions/sf/uok/auto-dispatch.js index 6d3c6160a..6c9795b8f 100644 --- a/src/resources/extensions/sf/uok/auto-dispatch.js +++ b/src/resources/extensions/sf/uok/auto-dispatch.js @@ -357,6 +357,24 @@ export function extractValidationAttentionPlan(validationContent) { if (tracking?.[1]?.trim()) return tracking[1].trim(); return null; } +function buildFallbackValidationAttentionPlan(validationContent) { + const excerpt = validationContent.trim().slice(0, 12_000); + return [ + "No structured remediation section was present in the validation artifact.", + "", + "Use the full validation artifact as the attention source:", + "", + "1. Inspect the DB-backed milestone, slice, task, summary, UAT, and validation state.", + "2. Identify whether the `needs-attention` rationale describes a real open defect, stale validation drift, or a follow-up that should be deferred.", + "3. If it is stale drift, update the current validation/remediation evidence so the next validation run evaluates the DB-backed state.", + "4. If it is a real defect, apply the smallest project or SF-state change that addresses it.", + "5. If it cannot be completed now, explicitly defer it with the concrete reason, owner/artifact, and revalidation evidence.", + "", + "## Validation Artifact Excerpt", + "", + excerpt || "(validation artifact was empty)", + ].join("\n"); +} function validationAttentionMarkerPath(basePath, mid) { return join( sfRoot(basePath), @@ -406,8 +424,8 @@ function validationAttentionRuntimePath(basePath, mid) { ); } function hasActiveValidationAttentionMarker(basePath, mid) { - const markerPath = validationAttentionMarkerPath(basePath, mid); - if (!existsSync(markerPath)) return false; + const marker = readValidationAttentionMarker(basePath, mid); + if (!marker) return false; if (existsSync(validationAttentionRuntimePath(basePath, mid))) return true; logWarning( "dispatch", @@ -1560,11 +1578,9 @@ export const DISPATCH_RULES = [ if (verdict && verdict !== "pass") { if (verdict === "needs-attention") { const attentionPlan = - extractValidationAttentionPlan(validationContent); - if ( - attentionPlan && - !hasActiveValidationAttentionMarker(basePath, mid) - ) { + extractValidationAttentionPlan(validationContent) ?? + buildFallbackValidationAttentionPlan(validationContent); + if (!hasActiveValidationAttentionMarker(basePath, mid)) { try { writeValidationAttentionMarker(basePath, mid, { milestoneId: mid, @@ -1914,8 +1930,8 @@ export const DISPATCH_RULES = [ }, ]; -import { getRegistry, hasRegistry } from "../rule-registry.js"; import { getErrorMessage } from "../error-utils.js"; +import { getRegistry, hasRegistry } from "../rule-registry.js"; // ─── Dispatch Envelope Emission ─────────────────────────────────────────── /** diff --git a/src/resources/extensions/sf/uok/index.js b/src/resources/extensions/sf/uok/index.js index 1f6af1d8d..eec3f9773 100644 --- a/src/resources/extensions/sf/uok/index.js +++ b/src/resources/extensions/sf/uok/index.js @@ -23,6 +23,11 @@ export { USER_SKILL_DIR, validateSkillFrontmatter, } from "../skills/index.js"; +export { + runAgentLoop, + runAgentTurn, + runSwarmTurn, +} from "./agent-runner.js"; // ─── Agent Swarm ─────────────────────────────────────────────────────────── export { AgentSwarm, ManagerType } from "./agent-swarm.js"; export { diff --git a/src/resources/extensions/sf/uok/persistent-agent.js b/src/resources/extensions/sf/uok/persistent-agent.js index 8c822a305..c8d99c412 100644 --- a/src/resources/extensions/sf/uok/persistent-agent.js +++ b/src/resources/extensions/sf/uok/persistent-agent.js @@ -8,27 +8,16 @@ * Consumer: AgentSwarm orchestrator, swarm role agents (CoordinatorAgent, WorkerAgent etc), * and direct use in multi-agent dispatch flows. * - * ## Current state - * This module implements the **container** half of a persistent agent: identity, inbox, - * memory blocks, and message routing. It does NOT implement the **runner** half. - * - * The missing piece is an LLM execution runner that: + * ## Runner + * The `agent-runner.js` module (sibling to this file) implements the LLM execution half: * 1. Reads pending messages from this agent's inbox (`receive(true)`) * 2. Assembles a prompt from core memory blocks + inbox messages * 3. Dispatches to SF headless (`node dist/loader.js headless --print `) * 4. Writes the LLM response back into the bus as a reply - * 5. Updates memory blocks (eviction, summarization) when context grows large + * 5. Limits context window to the most recent N turns * - * Until the runner exists, `PersistentAgent` is a passive store. The autonomous loop - * uses it this way for sleeptime memory consolidation (caller sends + immediately reads - * inbox). `SwarmDispatchLayer` also only enqueues messages — nothing processes them. - * - * When building the runner, key design decisions to make: - * - Context window management: how many inbox turns to include before summarizing - * - Memory eviction: which core blocks are injected, which are summarized to archival - * - Turn limits: max rounds before the runner yields and re-queues - * - Concurrency: one runner per agent name (enforce via DB lock or process mutex) - * - Error handling: failed LLM calls should leave the message as unread, not drop it + * `AgentSwarm.run()` optionally enables LLM-backed turns via `opts.enableLLM`. + * When disabled, the swarm acts as a pure message router (useful for tests). * * See: Codex `codex-rs/core/src/agent/control.rs` for the reference implementation of * typed parallel subagents (explorer/worker roles) with forked rollout history. diff --git a/src/tests/google-search-auth.repro.test.ts b/src/tests/google-search-auth.repro.test.ts index d107df735..ebe13db2b 100644 --- a/src/tests/google-search-auth.repro.test.ts +++ b/src/tests/google-search-auth.repro.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { afterEach, test } from "vitest"; -import googleSearchExtension from "../resources/extensions/google-search/index.js"; +import { googleSearchExtension } from "../resources/extensions/search-the-web/tool-google-search.js"; function createMockPI() { const handlers: any[] = []; diff --git a/src/tests/google-search-oauth-shape.test.ts b/src/tests/google-search-oauth-shape.test.ts index 42d1bf11c..6d3535a2d 100644 --- a/src/tests/google-search-oauth-shape.test.ts +++ b/src/tests/google-search-oauth-shape.test.ts @@ -8,7 +8,7 @@ import assert from "node:assert/strict"; import { afterEach, test, vi } from "vitest"; -import googleSearchExtension from "../resources/extensions/google-search/index.js"; +import { googleSearchExtension } from "../resources/extensions/search-the-web/tool-google-search.js"; const cliCoreMock = vi.hoisted(() => ({ lastRequest: undefined as any, diff --git a/src/tests/search-loop-guard.test.ts b/src/tests/search-loop-guard.test.ts index afa1e820d..eb01fb980 100644 --- a/src/tests/search-loop-guard.test.ts +++ b/src/tests/search-loop-guard.test.ts @@ -11,7 +11,7 @@ import assert from "node:assert/strict"; import { afterEach, test } from "vitest"; -import searchExtension from "../resources/extensions/search-the-web/index.ts"; +import searchExtension from "../resources/extensions/search-the-web/index.js"; import { registerSearchTool, resetSearchLoopGuardState, diff --git a/src/tests/ttsr-manager.test.ts b/src/tests/ttsr-manager.test.ts index 873a6d21e..bfaddeb8f 100644 --- a/src/tests/ttsr-manager.test.ts +++ b/src/tests/ttsr-manager.test.ts @@ -10,7 +10,7 @@ import { type Rule, TtsrManager, type TtsrMatchContext, -} from "../../src/resources/extensions/ttsr/index.js"; +} from "../../src/resources/extensions/guardrails/ttsr-manager.js"; // ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/src/tests/ttsr-rule-loader.test.ts b/src/tests/ttsr-rule-loader.test.ts index 57b2548fc..5d64e1e4e 100644 --- a/src/tests/ttsr-rule-loader.test.ts +++ b/src/tests/ttsr-rule-loader.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, test } from "vitest"; -import { loadRules } from "../../src/resources/extensions/ttsr/index.js"; +import { loadRules } from "../../src/resources/extensions/guardrails/ttsr-rule-loader.js"; // ─── Helpers ─────────────────────────────────────────────────────────────────