@tyvm/knowhow 0.0.144 → 0.0.145
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/bin/knowhow.js +22 -20
- package/package.json +1 -1
- package/scripts/migrate-usage-json.js +147 -0
- package/src/agents/base/base.ts +65 -10
- package/src/agents/tools/patch.ts +84 -2
- package/src/agents/tools/textSearch.ts +98 -3
- package/src/chat/modules/AgentModule.ts +129 -6
- package/src/chat/modules/SessionsModule.ts +6 -0
- package/src/chat/types.ts +14 -0
- package/src/chat.ts +11 -0
- package/src/cli.ts +2 -0
- package/src/clients/anthropic.ts +56 -1
- package/src/clients/contextLimits.ts +40 -0
- package/src/clients/gemini.ts +59 -5
- package/src/clients/http.ts +85 -4
- package/src/clients/index.ts +4 -0
- package/src/clients/openai.ts +179 -31
- package/src/clients/pricing/fireworks.ts +50 -1
- package/src/clients/pricing/index.ts +1 -1
- package/src/clients/pricing/models.ts +3 -1
- package/src/clients/pricing/openai.ts +2 -2
- package/src/clients/pricing/qwen.ts +80 -0
- package/src/clients/qwen.ts +16 -0
- package/src/clients/types.ts +69 -1
- package/src/clients/xai.ts +73 -4
- package/src/commands/agent.ts +20 -1
- package/src/index.ts +9 -0
- package/src/processors/Base64ImageDetector.ts +46 -7
- package/src/processors/GemmaToolProcessor.ts +163 -0
- package/src/processors/index.ts +1 -0
- package/src/services/AgentSyncFs.ts +37 -0
- package/src/services/SessionManager.ts +14 -0
- package/src/services/Tools.ts +3 -0
- package/src/services/modules/computerUse.ts +216 -0
- package/src/services/modules/index.ts +47 -9
- package/src/services/modules/types.ts +23 -1
- package/src/types.ts +3 -0
- package/tests/clients/contextLimits.test.ts +4 -0
- package/tests/clients/openai.images.test.ts +105 -0
- package/tests/clients/pricing.test.ts +6 -0
- package/tests/compressor/usageBase64.json +159 -0
- package/tests/compressor/usageBase64.test.ts +175 -0
- package/tests/patching/fixtures/transcript-mangles/agent-sync-fs-nested-catch.json +9 -0
- package/tests/patching/fixtures/transcript-mangles/automation-functions-crossed.json +9 -0
- package/tests/patching/fixtures/transcript-mangles/fruit-ninja-confirmed-retirement.json +8 -0
- package/tests/patching/patchFileOutput.test.ts +112 -1
- package/tests/patching/transcriptMangleRegression.test.ts +125 -0
- package/tests/processors/Base64ImageDetector.test.ts +36 -1
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.d.ts +8 -1
- package/ts_build/src/agents/base/base.js +37 -3
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/tools/patch.d.ts +1 -0
- package/ts_build/src/agents/tools/patch.js +35 -2
- package/ts_build/src/agents/tools/patch.js.map +1 -1
- package/ts_build/src/agents/tools/textSearch.js +56 -2
- package/ts_build/src/agents/tools/textSearch.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.d.ts +14 -1
- package/ts_build/src/chat/modules/AgentModule.js +73 -5
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/SessionsModule.js +4 -0
- package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
- package/ts_build/src/chat/types.d.ts +11 -0
- package/ts_build/src/chat.js +7 -0
- package/ts_build/src/chat.js.map +1 -1
- package/ts_build/src/cli.d.ts +2 -1
- package/ts_build/src/cli.js +1 -0
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/anthropic.js +28 -1
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/contextLimits.js +25 -0
- package/ts_build/src/clients/contextLimits.js.map +1 -1
- package/ts_build/src/clients/gemini.js +48 -4
- package/ts_build/src/clients/gemini.js.map +1 -1
- package/ts_build/src/clients/http.d.ts +2 -0
- package/ts_build/src/clients/http.js +66 -9
- package/ts_build/src/clients/http.js.map +1 -1
- package/ts_build/src/clients/index.d.ts +1 -0
- package/ts_build/src/clients/index.js +4 -0
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.d.ts +6 -1
- package/ts_build/src/clients/openai.js +131 -33
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/pricing/fireworks.d.ts +4 -1
- package/ts_build/src/clients/pricing/fireworks.js +33 -1
- package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
- package/ts_build/src/clients/pricing/index.d.ts +1 -1
- package/ts_build/src/clients/pricing/index.js +6 -2
- package/ts_build/src/clients/pricing/index.js.map +1 -1
- package/ts_build/src/clients/pricing/models.d.ts +2 -1
- package/ts_build/src/clients/pricing/models.js +4 -1
- package/ts_build/src/clients/pricing/models.js.map +1 -1
- package/ts_build/src/clients/pricing/openai.js +2 -2
- package/ts_build/src/clients/pricing/openai.js.map +1 -1
- package/ts_build/src/clients/pricing/qwen.d.ts +5 -0
- package/ts_build/src/clients/pricing/qwen.js +30 -0
- package/ts_build/src/clients/pricing/qwen.js.map +1 -0
- package/ts_build/src/clients/qwen.d.ts +4 -0
- package/ts_build/src/clients/qwen.js +15 -0
- package/ts_build/src/clients/qwen.js.map +1 -0
- package/ts_build/src/clients/types.d.ts +12 -0
- package/ts_build/src/clients/xai.d.ts +1 -0
- package/ts_build/src/clients/xai.js +56 -9
- package/ts_build/src/clients/xai.js.map +1 -1
- package/ts_build/src/commands/agent.js +10 -1
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/index.d.ts +2 -0
- package/ts_build/src/index.js +1 -0
- package/ts_build/src/index.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.js +22 -3
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/processors/GemmaToolProcessor.d.ts +14 -0
- package/ts_build/src/processors/GemmaToolProcessor.js +97 -0
- package/ts_build/src/processors/GemmaToolProcessor.js.map +1 -0
- package/ts_build/src/processors/index.d.ts +1 -0
- package/ts_build/src/processors/index.js +3 -1
- package/ts_build/src/processors/index.js.map +1 -1
- package/ts_build/src/services/AgentSyncFs.js +17 -0
- package/ts_build/src/services/AgentSyncFs.js.map +1 -1
- package/ts_build/src/services/SessionManager.js +12 -0
- package/ts_build/src/services/SessionManager.js.map +1 -1
- package/ts_build/src/services/Tools.d.ts +2 -0
- package/ts_build/src/services/Tools.js.map +1 -1
- package/ts_build/src/services/modules/computerUse.d.ts +163 -0
- package/ts_build/src/services/modules/computerUse.js +3 -0
- package/ts_build/src/services/modules/computerUse.js.map +1 -0
- package/ts_build/src/services/modules/index.d.ts +1 -0
- package/ts_build/src/services/modules/index.js +23 -6
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/src/services/modules/types.d.ts +4 -0
- package/ts_build/src/types.d.ts +3 -0
- package/ts_build/src/types.js.map +1 -1
- package/ts_build/src/workers/tools/index.d.ts +1 -1
- package/ts_build/tests/clients/contextLimits.test.js +2 -0
- package/ts_build/tests/clients/contextLimits.test.js.map +1 -1
- package/ts_build/tests/clients/openai.images.test.d.ts +1 -0
- package/ts_build/tests/clients/openai.images.test.js +94 -0
- package/ts_build/tests/clients/openai.images.test.js.map +1 -0
- package/ts_build/tests/clients/pricing.test.js +4 -0
- package/ts_build/tests/clients/pricing.test.js.map +1 -1
- package/ts_build/tests/compressor/usageBase64.test.d.ts +1 -0
- package/ts_build/tests/compressor/usageBase64.test.js +109 -0
- package/ts_build/tests/compressor/usageBase64.test.js.map +1 -0
- package/ts_build/tests/patching/patchFileOutput.test.js +82 -0
- package/ts_build/tests/patching/patchFileOutput.test.js.map +1 -1
- package/ts_build/tests/patching/transcriptMangleRegression.test.d.ts +1 -0
- package/ts_build/tests/patching/transcriptMangleRegression.test.js +104 -0
- package/ts_build/tests/patching/transcriptMangleRegression.test.js.map +1 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js +24 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/x-home.png +0 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"taskId": "1785594693-okay-just-tried-the-computer-use-agent-play-game",
|
|
3
|
+
"sourceBefore": "import * as fs from \"fs\";\nimport * as path from \"path\";\nimport {\n AccessibilityElement,\n AccessibilityOptions,\n Point,\n Region,\n} from \"@tyvm/knowhow\";\nimport {\n ComputerService,\n ColorRegion,\n DesktopBox,\n DesktopShape,\n OcrResult,\n ReadTextOptions,\n} from \"./ComputerService\";\nimport { resolveRegionAsync } from \"./regions\";\n\n/**\n * Automations \u2014 a locally-run perception\u2192action loop that the LLM authors once\n * (after watching a game/UI) and then launches with a single tool call. The LLM\n * (the slow, smart \"coach\") stays OUT of the per-frame loop; the automation (the\n * fast, dumb \"actor\") runs entirely in-process at a tight interval with no model\n * round-trip per target, which is the only way to beat the ~1s human bar.\n *\n * Design (see .knowhow/tasks/computer-use-improvements/implementation-plan.md):\n * - The automation is a .ts async function body with access to a small `sdk`.\n * - It can call `await sdk.requiredWindow(...)`; if OS focus leaves that window\n * the runner auto-pauses (action methods become no-ops), so a human\n * interrupts any automation just by clicking away / Cmd-Tabbing out.\n * - It supports `dryRun`, where action methods RECORD intent instead of moving\n * the real mouse \u2014 this powers offline testAutomation against a recording.\n */\n\nconst STORE_DIR = path.join(\".knowhow\", \"automations\");\n\n/**\n * Hard ceiling on how long ANY automation may run before it is force-stopped.\n * Automations take over the real mouse/keyboard, so a human needs to be able to\n * reliably reclaim control quickly. We cap every run at 10s (regardless of what\n * the caller requests) so a runaway automation can never hold the mouse hostage\n * for longer than that. Re-launch the automation if you legitimately need more time.\n */\nexport const MAX_AUTOMATION_DURATION_MS = 30000;\n\n/** Default run duration when the caller doesn't specify one. */\nexport const DEFAULT_AUTOMATION_DURATION_MS = 30000;\n\nexport interface WindowMatch {\n /** Substring (case-insensitive) that must appear in the active window title. */\n titleIncludes?: string;\n /** Substring (case-insensitive) that must appear in the active window app. */\n app?: string;\n}\n\nexport interface AutomationSpec {\n name: string;\n /** Async function body. Receives (sdk). May `await` sdk.* calls. */\n script: string;\n /** Parsed structured header (see parseAutomationDoc) describing when to use it. */\n doc?: AutomationDoc;\n /** Absolute path to the saved .ts file (populated by loaders). */\n filePath?: string;\n}\n\n/**\n * Structured \"skill card\" for an automation, parsed from a JSDoc-style header\n * comment at the very top of the script. This is what makes an automation\n * DISCOVERABLE: it tells a future agent what the automation does, WHEN to reach\n * for it, what the screen must look like before it runs, and what it leaves\n * behind afterward \u2014 so the agent can pick the right automation without reading\n * (or guessing at) the whole implementation.\n */\nexport interface AutomationDoc {\n description?: string;\n useWhen?: string;\n startState?: string;\n endState?: string;\n window?: string;\n notes?: string;\n}\n\nexport interface AutomationAction {\n t: number;\n kind:\n | \"clickAt\"\n | \"moveMouse\"\n | \"type\"\n | \"key\"\n | \"hotkey\"\n | \"focus\"\n | \"selectAccessibilityOption\"\n | \"setAccessibilityValue\"\n | \"performAccessibilityAction\";\n x?: number;\n y?: number;\n button?: string;\n text?: string;\n /** True when the runner suppressed this action (paused / dry-run). */\n suppressed?: boolean;\n}\n\nexport interface AutomationLogEntry {\n t: number;\n data: any;\n}\n\nexport interface AutomationRunResult {\n name: string;\n elapsedMs: number;\n stopped: \"duration\" | \"manual\" | \"completed\" | \"error\";\n error?: string;\n actions: AutomationAction[];\n actionCount: number;\n logs: AutomationLogEntry[];\n pausedMs: number;\n dryRun: boolean;\n /** The window gate that was in effect (if any) \u2014 the focus-loss auto-pause. */\n requiredWindow?: WindowMatch;\n /**\n * True when a LIVE run performed real actions without ever configuring a\n * required-window gate. Such a run can't be reclaimed by clicking away, so\n * callers should treat this as a warning.\n */\n ranWithoutWindowGate?: boolean;\n}\n\nexport interface AutomationControl {\n paused: boolean;\n stopped: boolean;\n requiredWindow?: WindowMatch;\n stop(): void;\n}\n\n/**\n * The surface handed to an authored automation script. Perception methods read\n * real rendered pixels; action methods drive the real mouse/keyboard (unless\n * paused or in dry-run). Everything is in ABSOLUTE DESKTOP coordinates.\n */\nexport interface AutomationSDK {\n // \u2500\u2500 perception (real pixels) \u2500\u2500\n screenSize(): Promise<{ width: number; height: number }>;\n /** Return the currently focused window, if one can be identified. */\n activeWindow(): Promise<{ title: string; app?: string; bounds?: Region } | null>;\n /**\n * Inspect the focused window's native accessibility tree. Element IDs are\n * short-lived: discover and use an element before requesting another tree.\n */\n accessibilityTrusted(): Promise<boolean>;\n accessibilityElements(opts?: AccessibilityOptions): Promise<AccessibilityElement[]>;\n findColor(\n colors: string | string[],\n opts?: {\n region?: Region | string;\n tolerance?: number;\n minPixels?: number;\n minSize?: number;\n maxSize?: number;\n }\n ): Promise<ColorRegion[]>;\n findShape(opts: {\n kind: \"line-h\" | \"line-v\" | \"rect\" | \"square\" | \"circle\" | \"blob\";\n color?: string;\n region?: Region | string;\n tolerance?: number;\n minSize?: number;\n maxSize?: number;\n length?: number;\n thickness?: number;\n }): Promise<DesktopShape[]>;\n findBoxes(opts?: {\n region?: Region | string;\n minSize?: number;\n maxSize?: number;\n minEdgeScore?: number;\n maxBoxes?: number;\n }): Promise<DesktopBox[]>;\n /**\n * Detect UI element regions by color segmentation, returned as a nested\n * containment hierarchy in ABSOLUTE DESKTOP coords (each box: bounds, center,\n * area, depth, children). Useful for \"find the clickable things in the play\n * area\" without knowing their exact color:\n * - mode \"panels\" (default here): find large flat BACKGROUND surfaces and\n * group the FOREGROUND content on each into element boxes (score readouts,\n * button rows) \u2014 i.e. things that AREN'T the background color.\n * - mode \"colors\": one box per contiguous same-color area, nested.\n * - mode \"foreground\": everything differing from the dominant background.\n * Pass a region to constrain the search (e.g. the playfield) so you only get\n * targets inside the game area.\n */\n findRegions(opts?: {\n region?: Region | string;\n mode?: \"foreground\" | \"colors\" | \"panels\";\n minSize?: number;\n colorBits?: number;\n clusterGap?: number;\n minPixels?: number;\n maxBoxes?: number;\n }): Promise<DesktopBox[]>;\n pixelColor(x: number, y: number): Promise<string>;\n\n // \u2500\u2500 action (no-op while paused; recorded in dry-run) \u2500\u2500\n clickAt(x: number, y: number, button?: \"left\" | \"right\" | \"middle\"): Promise<void>;\n moveMouse(x: number, y: number): Promise<void>;\n type(text: string): Promise<void>;\n key(name: string): Promise<void>;\n /** Press a key chord, e.g. `[\"command\", \"a\"]`. */\n hotkey(keys: string[]): Promise<void>;\n /** Focus an app/window. Call before installing requiredWindow. */\n focus(match: string): Promise<boolean>;\n /** Select a pop-up/combo-box option without keyboard type-ahead. */\n selectAccessibilityOption(id: string, option: string): Promise<void>;\n /** Set a settable accessibility element value using a fresh discovered ID. */\n setAccessibilityValue(id: string, value: string): Promise<void>;\n /** Perform an allowlisted AX action (for example AXPress or AXShowMenu). */\n performAccessibilityAction(id: string, action: string): Promise<void>;\n\n // \u2500\u2500 control / telemetry \u2500\u2500\n sleep(ms: number): Promise<void>;\n now(): number;\n elapsed(): number;\n log(data: any): void;\n /**\n * Run this callback repeatedly on a fixed interval (in MILLISECONDS, like\n * setInterval) until the run is stopped (or the max duration cap is\n * reached). The interval is measured between iteration starts; if a callback\n * overruns its interval it simply runs again immediately (after a yield).\n * Pass `intervalMs` of 0 to run as fast as possible. Optionally pass\n * `{ requiredWindow }` to gate the loop on a focused window in one call \u2014\n * equivalent to awaiting sdk.requiredWindow(match) first, so actions auto-\n * pause the moment focus leaves that window (the primary way a human reclaims\n * the mouse mid-run).\n */\n runEvery(\n callback: () => void | Promise<void>,\n intervalMs: number,\n opts?: { requiredWindow?: WindowMatch }\n ): Promise<void>;\n /** Set/clear the focus gate. Await before performing actions. */\n requiredWindow(match?: WindowMatch): Promise<void>;\n /**\n * Read text from the screen (or a named/explicit region) using macOS Vision\n * OCR. Returns recognized text regions in ABSOLUTE DESKTOP coordinates,\n * sorted top-to-bottom then left-to-right.\n * Each result: { text, confidence, bounds, center }\n * bounds/center are desktop coords ready for clickAt().\n * Pass a region to restrict OCR to part of the screen \u2014 faster, less noise.\n * On non-macOS platforms, returns [].\n */\n readText(opts?: {\n region?: Region | string;\n displayId?: number;\n activeWindow?: boolean;\n minConfidence?: number;\n }): Promise<OcrResult[]>;\n readonly ctl: AutomationControl;\n}\n\n/**\n * Editor/type-only binding for authored automation files. At execution time\n * the runner injects the real value and removes its SDK import declaration.\n */\nexport const sdk: AutomationSDK = undefined as any;\n\n// \u2500\u2500 registry (running instances, so stopAutomation can reach them) \u2500\u2500\nconst running = new Map<string, AutomationRunner>();\n\nexport function getRunning(name: string): AutomationRunner | undefined {\n return running.get(name);\n}\n\nexport function listRunning(): string[] {\n return [...running.keys()];\n}\n\n/** Persisted automation storage (git-trackable). */\nexport function automationPath(name: string): string {\n const safe = name.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n return path.join(STORE_DIR, `${safe}.ts`);\n}\n\n/**\n * Extract the structured \"skill card\" from an automation script. We look for a\n * leading block comment (before any executable code) and read JSDoc-style tags:\n * @description one-liner of what it does\n * @useWhen the situation/trigger that should make an agent pick this\n * @startState what the screen must look like BEFORE running\n * @endState what the screen will look like AFTER it finishes\n * @window the required window (title/app) it operates on\n * @notes anything else worth knowing (limits, caveats)\n * Tag text may wrap onto continuation lines. Returns undefined when no\n * recognizable header is present.\n */\nexport function parseAutomationDoc(script: string): AutomationDoc | undefined {\n const block = script.match(/^\\s*\\/\\*\\*?([\\s\\S]*?)\\*\\//);\n if (!block) return undefined;\n // Strip leading \" * \" decoration from each comment line.\n const body = block[1]\n .split(\"\\n\")\n .map((l) => l.replace(/^\\s*\\*?\\s?/, \"\"))\n .join(\"\\n\");\n\n const tags: Record<string, string> = {};\n let current: string | null = null;\n const known = new Set([\n \"description\",\n \"usewhen\",\n \"startstate\",\n \"endstate\",\n \"window\",\n \"notes\",\n ]);\n for (const rawLine of body.split(\"\\n\")) {\n const line = rawLine.trimEnd();\n const m = line.match(/^@(\\w+)\\s*(.*)$/);\n if (m && known.has(m[1].toLowerCase())) {\n current = m[1].toLowerCase();\n tags[current] = m[2].trim();\n } else if (current && line.trim()) {\n tags[current] = (tags[current] + \" \" + line.trim()).trim();\n }\n }\n if (!Object.keys(tags).length) return undefined;\n const doc: AutomationDoc = {\n description: tags[\"description\"],\n useWhen: tags[\"usewhen\"],\n startState: tags[\"startstate\"],\n endState: tags[\"endstate\"],\n window: tags[\"window\"],\n notes: tags[\"notes\"],\n };\n // Drop empty keys for a tidy summary.\n for (const k of Object.keys(doc) as (keyof AutomationDoc)[]) {\n if (!doc[k]) delete doc[k];\n }\n return Object.keys(doc).length ? doc : undefined;\n}\n\nexport function saveAutomation(spec: AutomationSpec): AutomationSpec {\n fs.mkdirSync(STORE_DIR, { recursive: true });\n fs.writeFileSync(automationPath(spec.name), spec.script);\n return { ...spec, doc: parseAutomationDoc(spec.script), filePath: automationPath(spec.name) };\n}\n\nexport function loadAutomation(name: string): AutomationSpec {\n const spec = loadAutomationSafe(name);\n if (!spec) throw new Error(`No automation named \"${name}\".`);\n return spec;\n}\n\nexport function loadAutomationSafe(name: string): AutomationSpec | undefined {\n try {\n const p = automationPath(name);\n if (fs.existsSync(p)) {\n const script = fs.readFileSync(p, \"utf8\");\n return { name, script, doc: parseAutomationDoc(script), filePath: p };\n }\n // Read old files during migration, but all new writes use plain .ts files.\n const safe = name.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n const legacy = path.join(STORE_DIR, `${safe}.automation.json`);\n if (!fs.existsSync(legacy)) return undefined;\n const parsed = JSON.parse(fs.readFileSync(legacy, \"utf8\"));\n return {\n name,\n script: parsed.script,\n doc: parseAutomationDoc(parsed.script || \"\"),\n filePath: legacy,\n };\n } catch {\n return undefined;\n }\n}\n\nexport function listAutomations(): AutomationSpec[] {\n try {\n if (!fs.existsSync(STORE_DIR)) return [];\n return fs\n .readdirSync(STORE_DIR)\n .filter((f) => f.endsWith(\".ts\"))\n .map((f) => loadAutomationSafe(f.slice(0, -3)) || null)\n .filter((x): x is AutomationSpec => !!x);\n } catch {\n return [];\n }\n}\n\nexport function deleteAutomation(name: string): boolean {\n const p = automationPath(name);\n let deleted = false;\n if (fs.existsSync(p)) {\n fs.unlinkSync(p);\n deleted = true;\n }\n const safe = name.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n const legacy = path.join(STORE_DIR, `${safe}.automation.json`);\n if (fs.existsSync(legacy)) {\n fs.unlinkSync(legacy);\n deleted = true;\n }\n return deleted;\n}\n\n/**\n * Reject scripts that try to escape the SDK sandbox. Automations must \"play\"\n * honestly \u2014 only real pixels in, real mouse out \u2014 so we forbid network/shell/\n * filesystem/module access. This is a static guard, not a full sandbox; the\n * script still runs with `new Function` and only `sdk` in scope.\n */\nconst FORBIDDEN_TOKENS = [\n \"require\",\n \"import\",\n \"process\",\n \"child_process\",\n \"fetch\",\n \"eval\",\n \"Function\",\n \"globalThis\",\n \"__dirname\",\n \"__filename\",\n \"XMLHttpRequest\",\n \"WebSocket\",\n];\n\nconst SDK_IMPORT = /^\\s*import\\s*\\{\\s*sdk\\s*\\}\\s*from\\s*[\"']@tyvm\\/knowhow-module-computer-use[\"']\\s*;?\\s*$/gm;\n\n/** Remove the one editor-only import supported by automation source files. */\nexport function prepareAutomationScript(script: string): string {\n return script.replace(SDK_IMPORT, \"\");\n}\n\nexport function validateScript(script: string): void {\n script = prepareAutomationScript(script);\n for (const tok of FORBIDDEN_TOKENS) {\n const re = new RegExp(`\\\\b${tok}\\\\b`);\n if (re.test(script)) {\n throw new Error(\n `Automation script rejected: forbidden token \"${tok}\". Automations may only use the provided sdk (perception + mouse/keyboard). No network, shell, filesystem, or module access.`\n );\n }\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nconst AsyncFunction = Object.getPrototypeOf(async function () {})\n .constructor as {\n new (...args: string[]): (...a: any[]) => Promise<any>;\n};\n\nexport interface RunOptions {\n maxDurationMs?: number;\n /** When true, action methods record intent but do NOT move the real mouse. */\n dryRun?: boolean;\n /** Poll interval for the window-focus gate (default 200ms). */\n gatePollMs?: number;\n /** Optional callback for streamed telemetry (e.g. push to the coach). */\n onLog?: (entry: AutomationLogEntry) => void;\n /**\n * Delay (ms) between the SDK's moveMouse and the click inside clickAt so the\n * OS commits the new cursor position before the button event \u2014 otherwise a\n * click posted back-to-back with a move can register at a stale location\n * (default 40).\n */\n clickSettleMs?: number;\n}\n\n/**\n * Runs a single automation instance: builds the SDK, enforces the window gate\n * (auto-pause on focus loss), guards a max duration, and collects telemetry.\n */\nexport class AutomationRunner {\n readonly spec: AutomationSpec;\n private svc: ComputerService;\n private opts: RunOptions;\n private startedAt = 0;\n private pausedAccumMs = 0;\n private pauseStartedAt = 0;\n private actions: AutomationAction[] = [];\n private logs: AutomationLogEntry[] = [];\n private gateTimer: NodeJS.Timeout | null = null;\n private durationTimer: NodeJS.Timeout | null = null;\n /** True once a non-empty required-window gate has been configured. */\n private everGatedWindow = false;\n /** Resolved max run duration (ms). Set at the start of run(). */\n maxDurationMs = DEFAULT_AUTOMATION_DURATION_MS;\n private stopReason: \"duration\" | \"manual\" | \"completed\" | \"error\" | null =\n null;\n\n readonly ctl: AutomationControl = {\n paused: false,\n stopped: false,\n requiredWindow: undefined,\n stop: () => {\n if (!this.ctl.stopped) this.stopReason = this.stopReason ?? \"manual\";\n this.ctl.stopped = true;\n },\n };\n\n constructor(spec: AutomationSpec, svc: ComputerService, opts: RunOptions = {}) {\n this.spec = spec;\n this.svc = svc;\n this.opts = opts;\n }\n\n private now(): number {\n return Date.now();\n }\n\n elapsed(): number {\n return this.startedAt ? this.now() - this.startedAt : 0;\n }\n\n private matchesWindow(\n active: { title: string; app?: string } | null,\n match?: WindowMatch\n ): boolean {\n if (!match || (!match.titleIncludes && !match.app)) return true;\n if (!active) return false;\n const title = (active.title || \"\").toLowerCase();\n const app = (active.app || \"\").toLowerCase();\n const titleMatches = !match.titleIncludes ||\n title.includes(match.titleIncludes.toLowerCase());\n const appMatches = !match.app || app.includes(match.app.toLowerCase());\n // When both selectors are supplied they describe one target window, not\n // alternatives. Requiring both prevents another Chrome tab from opening\n // the gate merely because its owning application matches.\n return titleMatches && appMatches;\n }\n\n private async pollGate(): Promise<void> {\n if (this.ctl.stopped) return;\n if (!this.ctl.requiredWindow) return;\n let active: { title: string; app?: string } | null = null;\n try {\n active = (await this.svc.getActiveWindow()) as any;\n } catch {\n active = null;\n }\n const ok = this.matchesWindow(active, this.ctl.requiredWindow);\n if (!ok && !this.ctl.paused) {\n this.ctl.paused = true;\n this.pauseStartedAt = this.now();\n this.emit({\n paused: \"window-focus-lost\",\n active: active?.title || null,\n });\n } else if (ok && this.ctl.paused) {\n this.ctl.paused = false;\n if (this.pauseStartedAt) {\n this.pausedAccumMs += this.now() - this.pauseStartedAt;\n this.pauseStartedAt = 0;\n }\n this.emit({ resumed: true, active: active?.title || null });\n }\n }\n\n private async configureRequiredWindow(match?: WindowMatch): Promise<void> {\n this.ctl.requiredWindow = match;\n if (match && (match.titleIncludes || match.app)) {\n this.everGatedWindow = true;\n }\n if (this.gateTimer) {\n clearInterval(this.gateTimer);\n this.gateTimer = null;\n }\n if (!match || (!match.titleIncludes && !match.app)) {\n if (this.ctl.paused) {\n this.ctl.paused = false;\n if (this.pauseStartedAt) {\n this.pausedAccumMs += this.now() - this.pauseStartedAt;\n this.pauseStartedAt = 0;\n }\n this.emit({ resumed: true, gateCleared: true });\n }\n return;\n }\n\n // Awaiting this function guarantees the next action is already gated.\n await this.pollGate();\n const gateMs = Math.max(50, this.opts.gatePollMs ?? 200);\n this.gateTimer = setInterval(() => {\n this.pollGate().catch(() => {});\n }, gateMs);\n if (this.gateTimer.unref) this.gateTimer.unref();\n }\n\n private emit(data: any): void {\n const entry: AutomationLogEntry = { t: this.elapsed(), data };\n this.logs.push(entry);\n if (this.opts.onLog) {\n try {\n this.opts.onLog(entry);\n } catch {\n /* streaming is best-effort */\n }\n }\n }\n\n private canAct(): boolean {\n return !this.ctl.stopped && !this.ctl.paused && !this.opts.dryRun;\n }\n\n private buildSDK(): AutomationSDK {\n const svc = this.svc;\n const self = this;\n const clickSettleMs = Math.max(0, this.opts.clickSettleMs ?? 40);\n const sdk: AutomationSDK = {\n ctl: this.ctl,\n runEvery: async (callback, intervalMs, opts) => {\n if (typeof callback !== \"function\") {\n throw new Error(\"sdk.runEvery(callback, intervalMs) requires a function.\");\n }\n if (!Number.isFinite(intervalMs) || intervalMs < 0) {\n throw new Error(\n \"sdk.runEvery(callback, intervalMs) requires a non-negative interval in milliseconds.\"\n );\n }\n // Inline focus gate: gate the loop on a window in one call so authors\n // don't forget the requiredWindow guard that lets a human reclaim the\n // mouse just by clicking away.\n if (opts?.requiredWindow) {\n await self.configureRequiredWindow(opts.requiredWindow);\n }\n // Hard backstop: even if the OS-level duration timer is starved (see\n // below), never let this loop outlive the configured max duration.\n const deadline = self.startedAt\n ? self.startedAt + self.maxDurationMs\n : Number.POSITIVE_INFINITY;\n while (!self.ctl.stopped) {\n const iterationStarted = self.now();\n // In-loop duration guard. The setTimeout-based durationTimer is a\n // macrotask; a callback that consistently runs longer than intervalMs\n // (screen capture + shape detection easily do) would otherwise leave\n // `remaining <= 0`, skip the setTimeout yield, and spin as a tight\n // microtask loop \u2014 starving timers AND SIGINT so neither the 30s\n // timeout nor Ctrl-C could ever fire. Check the deadline directly.\n if (iterationStarted >= deadline) {\n if (!self.ctl.stopped) {\n self.stopReason = self.stopReason ?? \"duration\";\n self.ctl.stopped = true;\n self.emit({ stopped: \"duration\", maxDurationMs: self.maxDurationMs });\n }\n break;\n }\n // A required-window gate scopes perception as well as input. Do not\n // start the callback while paused: OCR/screenshot calls are reads and\n // cannot be suppressed by canAct(), so running them here would capture\n // the terminal used to launch the automation on another display.\n if (self.ctl.paused) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n continue;\n }\n await callback();\n if (self.ctl.stopped) break;\n const remaining = intervalMs - (self.now() - iterationStarted);\n if (remaining > 0) {\n await new Promise((resolve) => setTimeout(resolve, remaining));\n } else {\n // The callback overran its interval budget (screen capture + shape\n // detection routinely do). Yield with a real timer tick rather than\n // setImmediate: setImmediate callbacks run in the event loop's\n // \"check\" phase and, when re-queued every iteration, can keep\n // starving the \"timers\" phase \u2014 which is exactly where the /poke\n // input watcher's setInterval and the agent's interrupt handling\n // live. A setTimeout(0) forces the loop through the timers phase\n // each iteration, so /poke (and Ctrl-C) stay responsive while an\n // automation is running hot.\n await new Promise((resolve) => setTimeout(resolve, 0));\n }\n }\n },\n requiredWindow: (match) => self.configureRequiredWindow(match),\n\n screenSize: () => svc.screenSize(),\n activeWindow: () => svc.getActiveWindow() as any,\n accessibilityTrusted: () => svc.accessibilityTrusted(),\n accessibilityElements: (o = {}) => svc.accessibilityElements(o),\n\n findColor: async (colors, o = {}) =>\n svc.findColorRegions({\n colors: Array.isArray(colors) ? colors : [colors],\n region: await resolveRegionAsync(o.region as any, () => svc.getActiveWindow()),\n tolerance: o.tolerance,\n minPixels: o.minPixels,\n minSize: o.minSize,\n maxSize: o.maxSize,\n } as any),\n\n findShape: async (o) =>\n svc.findShapes({\n kind: o.kind,\n color: o.color,\n region: await resolveRegionAsync(o.region as any, () => svc.getActiveWindow()),\n tolerance: o.tolerance,\n minSize: o.minSize,\n maxSize: o.maxSize,\n length: o.length,\n thickness: o.thickness,\n } as any),\n\n findBoxes: async (o = {}) =>\n svc.findBoxes({\n region: await resolveRegionAsync(o.region as any, () => svc.getActiveWindow()),\n minSize: o.minSize,\n maxSize: o.maxSize,\n minEdgeScore: o.minEdgeScore,\n maxBoxes: o.maxBoxes,\n } as any),\n\n findRegions: async (o = {}) =>\n svc.findRegions({\n region: await resolveRegionAsync(o.region as any, () => svc.getActiveWindow()),\n // Default to \"panels\" in automations: it groups foreground content\n // over background surfaces, which is what \"find the clickable things\n // that aren't the background\" usually means for a game/UI.\n mode: o.mode ?? \"panels\",\n minSize: o.minSize,\n colorBits: o.colorBits,\n clusterGap: o.clusterGap,\n minPixels: o.minPixels,\n maxBoxes: o.maxBoxes,\n } as any),\n\n pixelColor: (x, y) => svc.pixelColor({ x, y }),\n\n readText: (o = {}) => svc.readText(o as ReadTextOptions),\n\n clickAt: async (x, y, button = \"left\") => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"clickAt\",\n x,\n y,\n button,\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.moveMouse({ x, y });\n // Let the OS commit the cursor move before the button event; a click\n // fired back-to-back with a move can register at a stale position.\n if (clickSettleMs) await new Promise((r) => setTimeout(r, clickSettleMs));\n await svc.click(button);\n },\n\n moveMouse: async (x, y) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"moveMouse\",\n x,\n y,\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.moveMouse({ x, y });\n },\n\n type: async (text) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"type\",\n text,\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.typeText(text);\n },\n\n key: async (name) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"key\",\n text: name,\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.pressKey(name);\n },\n\n hotkey: async (keys) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"hotkey\",\n text: keys.join(\"+\"),\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.hotkey(...keys);\n },\n\n focus: async (match) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"focus\",\n text: match,\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n // Intentionally obeys the same dry-run, stopped, and focus-gate pause\n // rules as every other action. Authors that need to establish focus\n // must call this before requiredWindow/runEvery installs its gate.\n if (!self.canAct()) return false;\n return svc.focusWindow(match);\n },\n\n selectAccessibilityOption: async (id, option) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"selectAccessibilityOption\",\n text: JSON.stringify({ id, option }),\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.selectAccessibilityOption(id, option);\n },\n\n setAccessibilityValue: async (id, value) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"setAccessibilityValue\",\n text: JSON.stringify({ id, value }),\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.setAccessibilityValue(id, value);\n },\n\n performAccessibilityAction: async (id, action) => {\n const act: AutomationAction = {\n t: self.elapsed(),\n kind: \"performAccessibilityAction\",\n text: JSON.stringify({ id, action }),\n suppressed: !self.canAct(),\n };\n self.actions.push(act);\n if (!self.canAct()) return;\n await svc.performAccessibilityAction(id, action);\n },\n\n sleep: (ms) =>\n new Promise((resolve) => {\n const capped = Math.max(0, Math.min(60000, ms || 0));\n setTimeout(resolve, capped);\n }),\n\n now: () => self.now(),\n elapsed: () => self.elapsed(),\n log: (data) => self.emit(data),\n };\n return sdk;\n }\n\n async run(): Promise<AutomationRunResult> {\n const script = prepareAutomationScript(this.spec.script);\n validateScript(script);\n running.set(this.spec.name, this);\n this.startedAt = this.now();\n let stopped: AutomationRunResult[\"stopped\"] = \"completed\";\n let error: string | undefined;\n\n const maxDurationMs = (this.maxDurationMs = Math.max(\n 500,\n Math.min(\n MAX_AUTOMATION_DURATION_MS,\n this.opts.maxDurationMs ?? DEFAULT_AUTOMATION_DURATION_MS\n )\n ));\n this.durationTimer = setTimeout(() => {\n if (!this.ctl.stopped) {\n this.stopReason = \"duration\";\n this.ctl.stopped = true;\n this.emit({ stopped: \"duration\", maxDurationMs });\n }\n }, maxDurationMs);\n if (this.durationTimer.unref) this.durationTimer.unref();\n\n const sdk = this.buildSDK();\n const fn = new AsyncFunction(\"sdk\", script);\n try {\n await fn(sdk);\n // Script returned. The reason is whatever set ctl.stopped (duration/\n // manual), or a clean completion if nothing forced a stop.\n stopped = this.stopReason ?? \"completed\";\n } catch (e: any) {\n if (this.ctl.stopped && this.stopReason) {\n stopped = this.stopReason;\n } else {\n stopped = \"error\";\n error = e?.message || String(e);\n this.emit({ error });\n }\n } finally {\n this.ctl.stopped = true;\n if (this.gateTimer) clearInterval(this.gateTimer);\n if (this.durationTimer) clearTimeout(this.durationTimer);\n if (this.pauseStartedAt) {\n this.pausedAccumMs += this.now() - this.pauseStartedAt;\n this.pauseStartedAt = 0;\n }\n running.delete(this.spec.name);\n }\n\n return {\n name: this.spec.name,\n elapsedMs: this.elapsed(),\n stopped,\n error,\n actions: this.actions,\n actionCount: this.actions.length,\n logs: this.logs,\n pausedMs: this.pausedAccumMs,\n dryRun: !!this.opts.dryRun,\n requiredWindow: this.everGatedWindow ? this.ctl.requiredWindow : undefined,\n ranWithoutWindowGate:\n !this.opts.dryRun &&\n !this.everGatedWindow &&\n this.actions.some((a) => !a.suppressed),\n };\n }\n}\n",
|
|
4
|
+
"agentStatement": "The auto-correction badly scrambled these two functions. Let me rewrite lines 248-282 cleanly. Let me view starting at loadAutomationSafe definition.",
|
|
5
|
+
"patch": "@@ -173,10 +173,74 @@ export function listRunning(): string[] {\n /** Persisted automation storage (git-trackable). */\n export function automationPath(name: string): string {\n const safe = name.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n return path.join(STORE_DIR, `${safe}.ts`);\n }\n \n+/**\n+ * Extract the structured \"skill card\" from an automation script. We look for a\n+ * leading block comment (before any executable code) and read JSDoc-style tags:\n+ * @description one-liner of what it does\n+ * @useWhen the situation/trigger that should make an agent pick this\n+ * @startState what the screen must look like BEFORE running\n+ * @endState what the screen will look like AFTER it finishes\n+ * @window the required window (title/app) it operates on\n+ * @notes anything else worth knowing (limits, caveats)\n+ * Tag text may wrap onto continuation lines. Returns undefined when no\n+ * recognizable header is present.\n+ */\n+export function parseAutomationDoc(script: string): AutomationDoc | undefined {\n+ const block = script.match(/^\\s*\\/\\*\\*?([\\s\\S]*?)\\*\\//);\n+ if (!block) return undefined;\n+ // Strip leading \" * \" decoration from each comment line.\n+ const body = block[1]\n+ .split(\"\\n\")\n+ .map((l) => l.replace(/^\\s*\\*?\\s?/, \"\"))\n+ .join(\"\\n\");\n+\n+ const tags: Record<string, string> = {};\n+ let current: string | null = null;\n+ const known = new Set([\n+ \"description\",\n+ \"usewhen\",\n+ \"startstate\",\n+ \"endstate\",\n+ \"window\",\n+ \"notes\",\n+ ]);\n+ for (const rawLine of body.split(\"\\n\")) {\n+ const line = rawLine.trimEnd();\n+ const m = line.match(/^@(\\w+)\\s*(.*)$/);\n+ if (m && known.has(m[1].toLowerCase())) {\n+ current = m[1].toLowerCase();\n+ tags[current] = m[2].trim();\n+ } else if (current && line.trim()) {\n+ tags[current] = (tags[current] + \" \" + line.trim()).trim();\n+ }\n+ }\n+ if (!Object.keys(tags).length) return undefined;\n+ const doc: AutomationDoc = {\n+ description: tags[\"description\"],\n+ useWhen: tags[\"usewhen\"],\n+ startState: tags[\"startstate\"],\n+ endState: tags[\"endstate\"],\n+ window: tags[\"window\"],\n+ notes: tags[\"notes\"],\n+ };\n+ // Drop empty keys for a tidy summary.\n+ for (const k of Object.keys(doc) as (keyof AutomationDoc)[]) {\n+ if (!doc[k]) delete doc[k];\n+ }\n+ return Object.keys(doc).length ? doc : undefined;\n+}\n+\n export function saveAutomation(spec: AutomationSpec): AutomationSpec {\n fs.mkdirSync(STORE_DIR, { recursive: true });\n fs.writeFileSync(automationPath(spec.name), spec.script);\n- return spec;\n+ return { ...spec, doc: parseAutomationDoc(spec.script), filePath: automationPath(spec.name) };\n }\n \n@@ -191,14 +255,20 @@ export function loadAutomation(name: string): AutomationSpec {\n export function loadAutomationSafe(name: string): AutomationSpec | undefined {\n try {\n const p = automationPath(name);\n if (fs.existsSync(p)) {\n- return { name, script: fs.readFileSync(p, \"utf8\") };\n+ const script = fs.readFileSync(p, \"utf8\");\n+ return { name, script, doc: parseAutomationDoc(script), filePath: p };\n }\n // Read old files during migration, but all new writes use plain .ts files.\n const safe = name.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n const legacy = path.join(STORE_DIR, `${safe}.automation.json`);\n if (!fs.existsSync(legacy)) return undefined;\n const parsed = JSON.parse(fs.readFileSync(legacy, \"utf8\"));\n- return { name, script: parsed.script };\n+ return {\n+ name,\n+ script: parsed.script,\n+ doc: parseAutomationDoc(parsed.script || \"\"),\n+ filePath: legacy,\n+ };\n } catch {\n return undefined;\n }\n }\n",
|
|
6
|
+
"observedAfter": "250: const p = automationPath(name);\n251: if (fs.existsSync(p)) {\n252: return { name, script: fs.readFileSync(p, \"utf8\") };\n253: }\n254: // Read old files during migration, but all new writes use plain .ts files.\n255: const safe = name.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n256: const legacy = path.join(STORE_DIR, `${safe}.automation.json`);\n257: if (!fs.existsSync(legacy)) return undefined;\n258: const parsed = JSON.parse(fs.readFileSync(legacy, \"utf8\"));\n259: const script = fs.readFileSync(p, \"utf8\");\n260: return { name, script, doc: parseAutomationDoc(script), filePath: p };\n261: } catch {\n262: return undefined;\n263: }\n264: }\n265: \n266: export function listAutomations(): AutomationSpec[] {\n267: return {\n268: name,\n269: script: parsed.script,\n270: doc: parseAutomationDoc(parsed.script || \"\"),\n271: filePath: legacy,\n272: };\n273: if (!fs.existsSync(STORE_DIR)) return [];\n274: return fs\n275: .readdirSync(STORE_DIR)\n276: .filter((f) => f.endsWith(\".ts\"))\n277: .map((f) => loadAutomationSafe(f.slice(0, -3)) || null)\n278: .filter((x): x is AutomationSpec => !!x);\n279: } catch {\n280: return [];\n281: }\n282: }\n283: \n284: export function deleteAutomation(name: string): boolean {\n285: const p = automationPath(name);\n286: let deleted = false;\n287: if (fs.existsSync(p)) {\n288: fs.unlinkSync(p);\n289: deleted = true;\n290: }",
|
|
7
|
+
"sourceRevision": "30cdb6d",
|
|
8
|
+
"sourcePath": "packages/knowhow-module-computer-use/src/automation.ts"
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"taskId": "1785784065-can-you-fix-then-think-you-fix-the-confirmed",
|
|
3
|
+
"sourceBefore": "/**\n * @description Plays Fruit Ninja with one short predictive slash per stream-tracked fruit instead of chasing stale screenshot detections.\n * @window Fruit Ninja Benchmark — Google Chrome\n * @useWhen Chrome is showing Fruit Ninja and a human-like, visually precise alternative to fruitNinjaFull is desired.\n * @startState The Fruit Ninja tab is focused at the start screen, during a round, or on the completed results screen.\n * @endState The Game Over results remain visible. Play Again is never clicked.\n * @notes Uses sdk.trackObjects over a persistent 60 FPS stream. Stable track IDs guarantee at most one slash per tracked fruit; rising fruit are intercepted near their predicted apex and uncertain/descending fruit use a short latency-compensated intercept. Each slash is limited to roughly one fruit diameter.\n */\nimport { sdk } from '@tyvm/knowhow-module-computer-use';\n\nconst requiredWindow = { app: 'Google Chrome', titleIncludes: 'Fruit Ninja Benchmark' };\nconst slashedTrackIds = new Set();\nlet slashCount = 0;\nlet sawFruit = false;\nlet lastFruitAt = sdk.elapsed();\nlet lastLifecycleAt = -10000;\nlet tracker;\n\nasync function lifecyclePhase() {\n const elements = await sdk.accessibilityElements({ interactiveOnly: true });\n const button = elements.find((element) => {\n const text = `${element.title || ''} ${element.value || ''}`;\n return element.role === 'AXButton' && /Start Game|Play Again/i.test(text);\n });\n if (!button) return { phase: 'playing' };\n const text = `${button.title || ''} ${button.value || ''}`;\n return /Play Again/i.test(text) ? { phase: 'end' } : { phase: 'start', button };\n}\n\nasync function handleLifecycle() {\n lastLifecycleAt = sdk.elapsed();\n const lifecycle = await lifecyclePhase();\n if (lifecycle.phase === 'end') {\n sdk.log({ phase: 'end', slashCount, action: 'stop-with-results-visible' });\n sdk.ctl.stop();\n return false;\n }\n if (lifecycle.phase === 'start' && lifecycle.button?.bounds) {\n const bounds = lifecycle.button.bounds;\n await sdk.clickAt(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);\n sdk.log({ phase: 'start', action: 'click-start-game' });\n await sdk.sleep(150);\n }\n lastFruitAt = sdk.elapsed();\n return true;\n}\n\nasync function resolveArena(windowBounds) {\n const elements = await sdk.accessibilityElements({ interactiveOnly: false });\n const canvases = elements\n .filter((element) => element.role === 'AXCanvas' && element.bounds)\n .sort((a, b) => (b.bounds.width * b.bounds.height) - (a.bounds.width * a.bounds.height));\n if (canvases[0]) return canvases[0].bounds;\n\n const webAreas = elements\n .filter((element) => element.role === 'AXWebArea' && element.bounds)\n .sort((a, b) => (b.bounds.width * b.bounds.height) - (a.bounds.width * a.bounds.height));\n const page = webAreas[0]?.bounds || windowBounds;\n return {\n x: page.x,\n y: page.y + page.height * 0.06,\n width: page.width,\n height: page.height * 0.94,\n };\n}\n\nlet active = await sdk.activeWindow();\nwhile (!sdk.ctl.stopped && (active?.app !== requiredWindow.app\n || !active?.title?.includes(requiredWindow.titleIncludes))) {\n await sdk.sleep(50);\n active = await sdk.activeWindow();\n}\nif (!active?.bounds) throw new Error('The focused Fruit Ninja window has no usable bounds.');\n\nif (await handleLifecycle()) {\n const arena = await resolveArena(active.bounds);\n const expectedDiameter = arena.width * (70 / 1200);\n tracker = await sdk.trackObjects({\n region: arena,\n scale: 0.25,\n fps: 60,\n framesToKeep: 3,\n mode: 'foreground',\n minSize: Math.max(24, expectedDiameter * 0.55),\n maxSize: expectedDiameter * 1.45,\n minPixels: Math.max(250, expectedDiameter * expectedDiameter * 0.08),\n clusterGap: 4,\n dilate: 0,\n maxBoxes: 40,\n maxAssociationDistance: expectedDiameter * 2.2,\n historySize: 12,\n maxMissedFrames: 4,\n smoothing: 0.4,\n filter: (blob) => {\n const { width, height } = blob.bounds;\n const aspect = width / height;\n return aspect >= 0.72 && aspect <= 1.28\n && width >= expectedDiameter * 0.58 && width <= expectedDiameter * 1.45\n && height >= expectedDiameter * 0.58 && height <= expectedDiameter * 1.45;\n },\n });\n sdk.log({ phase: 'ready', perception: 'stream-tracker', arena, expectedDiameter,\n scale: 0.25, requestedFps: 60 });\n\n async function tick() {\n const observation = await tracker.nextFrame(250);\n const now = sdk.elapsed();\n if (!observation) {\n if (sawFruit && now - lastFruitAt > 4500 && now - lastLifecycleAt > 4500) {\n await handleLifecycle();\n }\n return;\n }\n const fruits = observation.objects.filter((object) => object.missedFrames === 0);\n if (fruits.length) {\n sawFruit = true;\n lastFruitAt = now;\n }\n\n for (const fruit of fruits) {\n if (slashedTrackIds.has(fruit.id) || fruit.ageFrames < 4) continue;\n\n // Wait for a trustworthy ballistic apex while the fruit is rising. If\n // acceleration has not stabilized after several frames, do not risk\n // waiting too long: intercept its latency-compensated future position.\n const speed = Math.hypot(fruit.velocity.x, fruit.velocity.y);\n const apexIsPlausible = fruit.apex\n && speed >= 120\n && fruit.velocity.y < -50\n && fruit.acceleration.y >= 300\n && fruit.acceleration.y <= 4000\n && fruit.apex.timeUntilMs > 0\n && fruit.apex.timeUntilMs < 1600\n && fruit.apex.point.x >= arena.x\n && fruit.apex.point.x <= arena.x + arena.width\n && fruit.apex.point.y >= arena.y\n && fruit.apex.point.y <= arena.y + arena.height;\n\n let target;\n let strategy;\n if (apexIsPlausible) {\n if (fruit.apex.timeUntilMs > 70) continue;\n target = fruit.apex.point;\n strategy = 'predicted-apex';\n } else if (speed >= 120 && (fruit.velocity.y >= -30 || fruit.ageFrames >= 10)) {\n target = fruit.predict(45);\n strategy = '45ms-intercept';\n } else {\n continue;\n }\n\n target = {\n x: Math.max(arena.x, Math.min(arena.x + arena.width, target.x)),\n y: Math.max(arena.y, Math.min(arena.y + arena.height, target.y)),\n };\n const radius = Math.max(fruit.bounds.width, fruit.bounds.height) / 2;\n const halfSlash = Math.min(expectedDiameter * 0.72, radius * 0.85);\n const x1 = Math.max(arena.x, target.x - halfSlash);\n const y1 = Math.min(arena.y + arena.height, target.y + halfSlash * 0.35);\n const x2 = Math.min(arena.x + arena.width, target.x + halfSlash);\n const y2 = Math.max(arena.y, target.y - halfSlash * 0.35);\n\n // Retire the stable object ID before awaiting input so no later frame can\n // schedule a second slash for the same moving fruit.\n slashedTrackIds.add(fruit.id);\n sdk.log({ phase: 'playing', trackId: fruit.id, strategy,\n ageFrames: fruit.ageFrames, confidence: +fruit.confidence.toFixed(2),\n observed: fruit.center, target,\n velocity: fruit.velocity, acceleration: fruit.acceleration,\n apexInMs: fruit.apex ? Math.round(fruit.apex.timeUntilMs) : null,\n detectionMs: observation.detectionMs, slash: { x1, y1, x2, y2 } });\n await sdk.dragMouse(x1, y1, x2, y2, { duration: 28 });\n slashCount++;\n }\n\n if (sawFruit && now - lastFruitAt > 4500 && now - lastLifecycleAt > 4500) {\n await handleLifecycle();\n }\n }\n\n await sdk.runEvery(tick, 20, { requiredWindow });\n}\n",
|
|
4
|
+
"patch": "--- .knowhow/automations/fruitNinjaPrecision.ts\n+++ .knowhow/automations/fruitNinjaPrecision.ts\n@@ -3,14 +3,17 @@\n * @useWhen Chrome is showing Fruit Ninja and a human-like, visually precise alternative to fruitNinjaFull is desired.\n * @startState The Fruit Ninja tab is focused at the start screen, during a round, or on the completed results screen.\n * @endState The Game Over results remain visible. Play Again is never clicked.\n- * @notes Uses sdk.trackObjects over a persistent 60 FPS stream. Stable track IDs guarantee at most one slash per tracked fruit; rising fruit are intercepted near their predicted apex and uncertain/descending fruit use a short latency-compensated intercept. Each slash is limited to roughly one fruit diameter.\n+ * @notes Uses sdk.trackObjects over a persistent 60 FPS stream. A slash is confirmed when its stable track disappears; a still-visible track receives at most one corrective slash. Rising fruit are intercepted near their predicted apex and uncertain/descending fruit use a short latency-compensated intercept. Each slash is limited to roughly one fruit diameter.\n */\n import { sdk } from '@tyvm/knowhow-module-computer-use';\n \n const requiredWindow = { app: 'Google Chrome', titleIncludes: 'Fruit Ninja Benchmark' };\n-const slashedTrackIds = new Set();\n+const slashStates = new Map();\n+const FOLLOW_UP_DELAY_MS = 130;\n+const FOLLOW_UP_FRAMES = 3;\n+const CONFIRM_MISSED_FRAMES = 2;\n let slashCount = 0;\n let sawFruit = false;\n let lastFruitAt = sdk.elapsed();\n@@ -104,13 +107,35 @@\n }\n return;\n }\n+ for (const fruit of observation.objects) {\n+ const slashState = slashStates.get(fruit.id);\n+ if (slashState && !slashState.confirmed\n+ && fruit.missedFrames >= CONFIRM_MISSED_FRAMES) {\n+ slashState.confirmed = true;\n+ sdk.log({ phase: 'playing', trackId: fruit.id, action: 'slash-confirmed',\n+ attempts: slashState.attempts, missedFrames: fruit.missedFrames });\n+ }\n+ }\n const fruits = observation.objects.filter((object) => object.missedFrames === 0);\n if (fruits.length) {\n sawFruit = true;\n lastFruitAt = now;\n }\n \n for (const fruit of fruits) {\n- if (slashedTrackIds.has(fruit.id) || fruit.ageFrames < 4) continue;\n+ const slashState = slashStates.get(fruit.id);\n+ if (slashState?.confirmed || slashState?.attempts >= 2 || fruit.ageFrames < 4) continue;\n+\n+ const isFollowUp = slashState?.attempts === 1;\n+ if (isFollowUp && (now - slashState.lastAttemptAt < FOLLOW_UP_DELAY_MS\n+ || observation.frame.sequence - slashState.lastAttemptSequence < FOLLOW_UP_FRAMES)) {\n+ continue;\n+ }\n \n // Wait for a trustworthy ballistic apex while the fruit is rising. If\n // acceleration has not stabilized after several frames, do not risk\n@@ -131,7 +156,11 @@\n \n let target;\n let strategy;\n- if (apexIsPlausible) {\n+ if (isFollowUp) {\n+ target = fruit.predict(45);\n+ strategy = 'corrective-45ms-intercept';\n+ } else if (apexIsPlausible) {\n if (fruit.apex.timeUntilMs > 70) continue;\n target = fruit.apex.point;\n strategy = 'predicted-apex';\n@@ -153,10 +182,14 @@\n const x2 = Math.min(arena.x + arena.width, target.x + halfSlash);\n const y2 = Math.max(arena.y, target.y - halfSlash * 0.35);\n \n- // Retire the stable object ID before awaiting input so no later frame can\n- // schedule a second slash for the same moving fruit.\n- slashedTrackIds.add(fruit.id);\n+ // Record the attempt before awaiting input, but only retire the track\n+ // after it disappears. A fruit that remains visible gets one correction.\n+ const nextSlashState = slashState || { attempts: 0, confirmed: false };\n+ nextSlashState.attempts++;\n+ nextSlashState.lastAttemptAt = sdk.elapsed();\n+ nextSlashState.lastAttemptSequence = observation.frame.sequence;\n+ slashStates.set(fruit.id, nextSlashState);\n sdk.log({ phase: 'playing', trackId: fruit.id, strategy,\n+ attempt: nextSlashState.attempts,\n ageFrames: fruit.ageFrames, confidence: +fruit.confidence.toFixed(2),\n observed: fruit.center, target,\n velocity: fruit.velocity, acceleration: fruit.acceleration,\n",
|
|
5
|
+
"expectedAfter": "/**\n * @description Plays Fruit Ninja with one short predictive slash per stream-tracked fruit instead of chasing stale screenshot detections.\n * @window Fruit Ninja Benchmark — Google Chrome\n * @useWhen Chrome is showing Fruit Ninja and a human-like, visually precise alternative to fruitNinjaFull is desired.\n * @startState The Fruit Ninja tab is focused at the start screen, during a round, or on the completed results screen.\n * @endState The Game Over results remain visible. Play Again is never clicked.\n * @notes Uses sdk.trackObjects over a persistent 60 FPS stream. A slash is confirmed when its stable track disappears; a still-visible track receives at most one corrective slash. Rising fruit are intercepted near their predicted apex and uncertain/descending fruit use a short latency-compensated intercept. Each slash is limited to roughly one fruit diameter.\n */\nimport { sdk } from '@tyvm/knowhow-module-computer-use';\n\nconst requiredWindow = { app: 'Google Chrome', titleIncludes: 'Fruit Ninja Benchmark' };\nconst slashStates = new Map();\nconst FOLLOW_UP_DELAY_MS = 130;\nconst FOLLOW_UP_FRAMES = 3;\nconst CONFIRM_MISSED_FRAMES = 2;\nlet slashCount = 0;\nlet sawFruit = false;\nlet lastFruitAt = sdk.elapsed();\nlet lastLifecycleAt = -10000;\nlet tracker;\n\nasync function lifecyclePhase() {\n const elements = await sdk.accessibilityElements({ interactiveOnly: true });\n const button = elements.find((element) => {\n const text = `${element.title || ''} ${element.value || ''}`;\n return element.role === 'AXButton' && /Start Game|Play Again/i.test(text);\n });\n if (!button) return { phase: 'playing' };\n const text = `${button.title || ''} ${button.value || ''}`;\n return /Play Again/i.test(text) ? { phase: 'end' } : { phase: 'start', button };\n}\n\nasync function handleLifecycle() {\n lastLifecycleAt = sdk.elapsed();\n const lifecycle = await lifecyclePhase();\n if (lifecycle.phase === 'end') {\n sdk.log({ phase: 'end', slashCount, action: 'stop-with-results-visible' });\n sdk.ctl.stop();\n return false;\n }\n if (lifecycle.phase === 'start' && lifecycle.button?.bounds) {\n const bounds = lifecycle.button.bounds;\n await sdk.clickAt(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);\n sdk.log({ phase: 'start', action: 'click-start-game' });\n await sdk.sleep(150);\n }\n lastFruitAt = sdk.elapsed();\n return true;\n}\n\nasync function resolveArena(windowBounds) {\n const elements = await sdk.accessibilityElements({ interactiveOnly: false });\n const canvases = elements\n .filter((element) => element.role === 'AXCanvas' && element.bounds)\n .sort((a, b) => (b.bounds.width * b.bounds.height) - (a.bounds.width * a.bounds.height));\n if (canvases[0]) return canvases[0].bounds;\n\n const webAreas = elements\n .filter((element) => element.role === 'AXWebArea' && element.bounds)\n .sort((a, b) => (b.bounds.width * b.bounds.height) - (a.bounds.width * a.bounds.height));\n const page = webAreas[0]?.bounds || windowBounds;\n return {\n x: page.x,\n y: page.y + page.height * 0.06,\n width: page.width,\n height: page.height * 0.94,\n };\n}\n\nlet active = await sdk.activeWindow();\nwhile (!sdk.ctl.stopped && (active?.app !== requiredWindow.app\n || !active?.title?.includes(requiredWindow.titleIncludes))) {\n await sdk.sleep(50);\n active = await sdk.activeWindow();\n}\nif (!active?.bounds) throw new Error('The focused Fruit Ninja window has no usable bounds.');\n\nif (await handleLifecycle()) {\n const arena = await resolveArena(active.bounds);\n const expectedDiameter = arena.width * (70 / 1200);\n tracker = await sdk.trackObjects({\n region: arena,\n scale: 0.25,\n fps: 60,\n framesToKeep: 3,\n mode: 'foreground',\n minSize: Math.max(24, expectedDiameter * 0.55),\n maxSize: expectedDiameter * 1.45,\n minPixels: Math.max(250, expectedDiameter * expectedDiameter * 0.08),\n clusterGap: 4,\n dilate: 0,\n maxBoxes: 40,\n maxAssociationDistance: expectedDiameter * 2.2,\n historySize: 12,\n maxMissedFrames: 4,\n smoothing: 0.4,\n filter: (blob) => {\n const { width, height } = blob.bounds;\n const aspect = width / height;\n return aspect >= 0.72 && aspect <= 1.28\n && width >= expectedDiameter * 0.58 && width <= expectedDiameter * 1.45\n && height >= expectedDiameter * 0.58 && height <= expectedDiameter * 1.45;\n },\n });\n sdk.log({ phase: 'ready', perception: 'stream-tracker', arena, expectedDiameter,\n scale: 0.25, requestedFps: 60 });\n\n async function tick() {\n const observation = await tracker.nextFrame(250);\n const now = sdk.elapsed();\n if (!observation) {\n if (sawFruit && now - lastFruitAt > 4500 && now - lastLifecycleAt > 4500) {\n await handleLifecycle();\n }\n return;\n }\n for (const fruit of observation.objects) {\n const slashState = slashStates.get(fruit.id);\n if (slashState && !slashState.confirmed\n && fruit.missedFrames >= CONFIRM_MISSED_FRAMES) {\n slashState.confirmed = true;\n sdk.log({ phase: 'playing', trackId: fruit.id, action: 'slash-confirmed',\n attempts: slashState.attempts, missedFrames: fruit.missedFrames });\n }\n }\n const fruits = observation.objects.filter((object) => object.missedFrames === 0);\n if (fruits.length) {\n sawFruit = true;\n lastFruitAt = now;\n }\n\n for (const fruit of fruits) {\n const slashState = slashStates.get(fruit.id);\n if (slashState?.confirmed || slashState?.attempts >= 2 || fruit.ageFrames < 4) continue;\n\n const isFollowUp = slashState?.attempts === 1;\n if (isFollowUp && (now - slashState.lastAttemptAt < FOLLOW_UP_DELAY_MS\n || observation.frame.sequence - slashState.lastAttemptSequence < FOLLOW_UP_FRAMES)) {\n continue;\n }\n\n // Wait for a trustworthy ballistic apex while the fruit is rising. If\n // acceleration has not stabilized after several frames, do not risk\n // waiting too long: intercept its latency-compensated future position.\n const speed = Math.hypot(fruit.velocity.x, fruit.velocity.y);\n const apexIsPlausible = fruit.apex\n && speed >= 120\n && fruit.velocity.y < -50\n && fruit.acceleration.y >= 300\n && fruit.acceleration.y <= 4000\n && fruit.apex.timeUntilMs > 0\n && fruit.apex.timeUntilMs < 1600\n && fruit.apex.point.x >= arena.x\n && fruit.apex.point.x <= arena.x + arena.width\n && fruit.apex.point.y >= arena.y\n && fruit.apex.point.y <= arena.y + arena.height;\n\n let target;\n let strategy;\n if (isFollowUp) {\n target = fruit.predict(45);\n strategy = 'corrective-45ms-intercept';\n } else if (apexIsPlausible) {\n if (fruit.apex.timeUntilMs > 70) continue;\n target = fruit.apex.point;\n strategy = 'predicted-apex';\n } else if (speed >= 120 && (fruit.velocity.y >= -30 || fruit.ageFrames >= 10)) {\n target = fruit.predict(45);\n strategy = '45ms-intercept';\n } else {\n continue;\n }\n\n target = {\n x: Math.max(arena.x, Math.min(arena.x + arena.width, target.x)),\n y: Math.max(arena.y, Math.min(arena.y + arena.height, target.y)),\n };\n const radius = Math.max(fruit.bounds.width, fruit.bounds.height) / 2;\n const halfSlash = Math.min(expectedDiameter * 0.72, radius * 0.85);\n const x1 = Math.max(arena.x, target.x - halfSlash);\n const y1 = Math.min(arena.y + arena.height, target.y + halfSlash * 0.35);\n const x2 = Math.min(arena.x + arena.width, target.x + halfSlash);\n const y2 = Math.max(arena.y, target.y - halfSlash * 0.35);\n\n // Record the attempt before awaiting input, but only retire the track\n // after it disappears. A fruit that remains visible gets one correction.\n const nextSlashState = slashState || { attempts: 0, confirmed: false };\n nextSlashState.attempts++;\n nextSlashState.lastAttemptAt = sdk.elapsed();\n nextSlashState.lastAttemptSequence = observation.frame.sequence;\n slashStates.set(fruit.id, nextSlashState);\n sdk.log({ phase: 'playing', trackId: fruit.id, strategy,\n attempt: nextSlashState.attempts,\n ageFrames: fruit.ageFrames, confidence: +fruit.confidence.toFixed(2),\n observed: fruit.center, target,\n velocity: fruit.velocity, acceleration: fruit.acceleration,\n apexInMs: fruit.apex ? Math.round(fruit.apex.timeUntilMs) : null,\n detectionMs: observation.detectionMs, slash: { x1, y1, x2, y2 } });\n await sdk.dragMouse(x1, y1, x2, y2, { duration: 28 });\n slashCount++;\n }\n\n if (sawFruit && now - lastFruitAt > 4500 && now - lastLifecycleAt > 4500) {\n await handleLifecycle();\n }\n }\n\n await sdk.runEvery(tick, 20, { requiredWindow });\n}\n",
|
|
6
|
+
"observedAfter": "",
|
|
7
|
+
"agentStatement": "The patch tool reported a clean application, but crossed the targeting and slash-state blocks and produced invalid TypeScript."
|
|
8
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
autoCorrectionPreservesChanges,
|
|
3
|
+
patchFile,
|
|
4
|
+
} from "../../src/agents/tools/patch";
|
|
2
5
|
import * as fs from "fs";
|
|
3
6
|
|
|
4
7
|
const mockToolService = {
|
|
@@ -215,3 +218,111 @@ describe("patchFile nested-object + leading-context additions regression", () =>
|
|
|
215
218
|
expect(countChar(updated, "}")).toBe(countChar(original, "}"));
|
|
216
219
|
});
|
|
217
220
|
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Regression for a real multi-hunk patchFile failure observed while removing
|
|
224
|
+
* an environment filter from several similar Prisma queries. Auto-correction
|
|
225
|
+
* reported success, but removed unrelated predicates and moved lines between
|
|
226
|
+
* query blocks. A safe result is either the exact requested removals or a
|
|
227
|
+
* rejected patch which leaves the file untouched.
|
|
228
|
+
*/
|
|
229
|
+
describe("patchFile repeated Prisma query regression", () => {
|
|
230
|
+
const testFile = "/tmp/patch-repeated-prisma-regression.ts";
|
|
231
|
+
const original = `async function reconcile() {
|
|
232
|
+
const orphaned = await prisma.githubRunnerJob.findMany({
|
|
233
|
+
where: {
|
|
234
|
+
status: "running",
|
|
235
|
+
updatedAt: { lt: cutoff },
|
|
236
|
+
...this.runnerJobEnvironmentFilter(),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const expired = await prisma.githubRunnerJob.findMany({
|
|
241
|
+
where: {
|
|
242
|
+
status: { in: ["dispatched", "running"] },
|
|
243
|
+
leaseExpiresAt: { lt: now },
|
|
244
|
+
leaseToken: { not: null },
|
|
245
|
+
...this.runnerJobEnvironmentFilter(),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const retryable = await prisma.githubRunnerJob.findMany({
|
|
250
|
+
where: {
|
|
251
|
+
status: "retry_wait",
|
|
252
|
+
attemptCount: { lt: MAX_ATTEMPTS },
|
|
253
|
+
nextRetryAt: { lte: now },
|
|
254
|
+
...this.runnerJobEnvironmentFilter(),
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const queued = await prisma.githubRunnerJob.findMany({
|
|
259
|
+
where: {
|
|
260
|
+
status: "queued",
|
|
261
|
+
leaseToken: null,
|
|
262
|
+
sandboxId: null,
|
|
263
|
+
...this.runnerJobEnvironmentFilter(),
|
|
264
|
+
},
|
|
265
|
+
orderBy: [{ createdAt: "asc" }],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
|
|
270
|
+
beforeEach(() => fs.writeFileSync(testFile, original));
|
|
271
|
+
|
|
272
|
+
it("only removes the requested repeated spread properties or rejects without writing", async () => {
|
|
273
|
+
// The wildly stale headers exercise patchFile's fuzzy-application or
|
|
274
|
+
// auto-correction path (depending on diff-library behavior). Each hunk has
|
|
275
|
+
// short, similar context, matching the shape that crossed query scopes.
|
|
276
|
+
const patch = `@@ -100,2 +100,1 @@
|
|
277
|
+
updatedAt: { lt: cutoff },
|
|
278
|
+
- ...this.runnerJobEnvironmentFilter(),
|
|
279
|
+
@@ -200,2 +200,1 @@
|
|
280
|
+
leaseToken: { not: null },
|
|
281
|
+
- ...this.runnerJobEnvironmentFilter(),
|
|
282
|
+
@@ -300,2 +300,1 @@
|
|
283
|
+
nextRetryAt: { lte: now },
|
|
284
|
+
- ...this.runnerJobEnvironmentFilter(),
|
|
285
|
+
@@ -400,2 +400,1 @@
|
|
286
|
+
sandboxId: null,
|
|
287
|
+
- ...this.runnerJobEnvironmentFilter(),
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
const result = await boundPatch(testFile, patch);
|
|
291
|
+
const updated = fs.readFileSync(testFile, "utf8");
|
|
292
|
+
|
|
293
|
+
if (result.includes("❌ Patch failed")) {
|
|
294
|
+
expect(updated).toBe(original);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const expected = original.replace(
|
|
299
|
+
/^\s*\.\.\.this\.runnerJobEnvironmentFilter\(\),\n/gm,
|
|
300
|
+
""
|
|
301
|
+
);
|
|
302
|
+
expect(updated).toBe(expected);
|
|
303
|
+
|
|
304
|
+
// Explicitly protect the predicates and object boundaries corrupted in the
|
|
305
|
+
// original incident.
|
|
306
|
+
expect(updated).toContain('status: "queued",');
|
|
307
|
+
expect(updated).toContain("leaseToken: null,");
|
|
308
|
+
expect(updated).toContain("sandboxId: null,");
|
|
309
|
+
expect(updated).toContain("attemptCount: { lt: MAX_ATTEMPTS },");
|
|
310
|
+
expect(updated).toContain("orderBy: [{ createdAt: \"asc\" }],");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("detects the unrelated removals from the recorded corruption", () => {
|
|
314
|
+
const requestedPatch = `@@ -30,2 +30,1 @@
|
|
315
|
+
sandboxId: null,
|
|
316
|
+
- ...this.runnerJobEnvironmentFilter(),
|
|
317
|
+
`;
|
|
318
|
+
const corrupted = original
|
|
319
|
+
.replace(' status: "queued",\n', "")
|
|
320
|
+
.replace(" leaseToken: null,\n", "")
|
|
321
|
+
.replace(" sandboxId: null,\n", "")
|
|
322
|
+
.replace(" ...this.runnerJobEnvironmentFilter(),\n", "");
|
|
323
|
+
|
|
324
|
+
expect(
|
|
325
|
+
autoCorrectionPreservesChanges(original, corrupted, requestedPatch)
|
|
326
|
+
).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { patchFile } from "../../src/agents/tools/patch";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
type Fixture = {
|
|
7
|
+
taskId: string;
|
|
8
|
+
sourceBefore: string;
|
|
9
|
+
patch: string;
|
|
10
|
+
observedAfter: string;
|
|
11
|
+
expectedAfter?: string;
|
|
12
|
+
agentStatement: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const fixtureDir = path.join(__dirname, "fixtures", "transcript-mangles");
|
|
16
|
+
const loadFixture = (name: string): Fixture =>
|
|
17
|
+
JSON.parse(fs.readFileSync(path.join(fixtureDir, `${name}.json`), "utf8"));
|
|
18
|
+
const mockToolService = { getContext: () => ({ Events: null }) };
|
|
19
|
+
const boundPatch = (patchFile as any).bind(mockToolService);
|
|
20
|
+
|
|
21
|
+
function replaceOnce(source: string, before: string, after: string): string {
|
|
22
|
+
expect(source.split(before)).toHaveLength(2);
|
|
23
|
+
return source.replace(before, after);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function expectedAgentSyncFs(source: string): string {
|
|
27
|
+
let expected = replaceOnce(
|
|
28
|
+
source,
|
|
29
|
+
` * consecutive calls can reveal when a cache prefix was invalidated.\n */`,
|
|
30
|
+
` * consecutive calls can reveal when a cache prefix was invalidated.\n *\n * To prevent exponential file growth, if the new entry's message thread\n * fully contains the previous entry's thread as a prefix, the previous\n * thread is replaced with { PREV_CACHE_HIT: true } so only the new\n * messages (the delta) are stored alongside the new usage data.\n */`
|
|
31
|
+
);
|
|
32
|
+
expected = replaceOnce(
|
|
33
|
+
expected,
|
|
34
|
+
` entries.push(entry);`,
|
|
35
|
+
` // If the new entry's messages are a superset of the last entry's\n // messages (i.e. the last thread is a prefix of the new thread),\n // collapse the last entry's messages to { PREV_CACHE_HIT: true }\n // and store only the new delta — preventing exponential growth.\n if (entries.length > 0) {\n const lastEntry = entries[entries.length - 1];\n const prevMessages: any[] = lastEntry?.messages;\n const newMessages: any[] = entry?.messages;\n if (\n Array.isArray(prevMessages) &&\n Array.isArray(newMessages) &&\n prevMessages.length > 0 &&\n newMessages.length >= prevMessages.length &&\n JSON.stringify(newMessages.slice(0, prevMessages.length)) ===\n JSON.stringify(prevMessages)\n ) {\n // Replace the previous full thread with a compact sentinel\n lastEntry.messages = [{ PREV_CACHE_HIT: true }];\n }\n }\n\n entries.push(entry);`
|
|
36
|
+
);
|
|
37
|
+
return expected;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function expectedAutomation(source: string): string {
|
|
41
|
+
const anchor = `export function automationPath(name: string): string {\n const safe = name.replace(/[^a-zA-Z0-9_-]/g, "_");\n return path.join(STORE_DIR, \`\${safe}.ts\`);\n}\n`;
|
|
42
|
+
const parser = `\n/**\n * Extract the structured "skill card" from an automation script. We look for a\n * leading block comment (before any executable code) and read JSDoc-style tags:\n * @description one-liner of what it does\n * @useWhen the situation/trigger that should make an agent pick this\n * @startState what the screen must look like BEFORE running\n * @endState what the screen will look like AFTER it finishes\n * @window the required window (title/app) it operates on\n * @notes anything else worth knowing (limits, caveats)\n * Tag text may wrap onto continuation lines. Returns undefined when no\n * recognizable header is present.\n */\nexport function parseAutomationDoc(script: string): AutomationDoc | undefined {\n const block = script.match(/^\\s*\\/\\*\\*?([\\s\\S]*?)\\*\\//);\n if (!block) return undefined;\n // Strip leading " * " decoration from each comment line.\n const body = block[1]\n .split("\\n")\n .map((l) => l.replace(/^\\s*\\*?\\s?/, ""))\n .join("\\n");\n\n const tags: Record<string, string> = {};\n let current: string | null = null;\n const known = new Set([\n "description",\n "usewhen",\n "startstate",\n "endstate",\n "window",\n "notes",\n ]);\n for (const rawLine of body.split("\\n")) {\n const line = rawLine.trimEnd();\n const m = line.match(/^@(\\w+)\\s*(.*)$/);\n if (m && known.has(m[1].toLowerCase())) {\n current = m[1].toLowerCase();\n tags[current] = m[2].trim();\n } else if (current && line.trim()) {\n tags[current] = (tags[current] + " " + line.trim()).trim();\n }\n }\n if (!Object.keys(tags).length) return undefined;\n const doc: AutomationDoc = {\n description: tags["description"],\n useWhen: tags["usewhen"],\n startState: tags["startstate"],\n endState: tags["endstate"],\n window: tags["window"],\n notes: tags["notes"],\n };\n // Drop empty keys for a tidy summary.\n for (const k of Object.keys(doc) as (keyof AutomationDoc)[]) {\n if (!doc[k]) delete doc[k];\n }\n return Object.keys(doc).length ? doc : undefined;\n}\n`;
|
|
43
|
+
let expected = replaceOnce(source, anchor, anchor + parser);
|
|
44
|
+
expected = replaceOnce(
|
|
45
|
+
expected,
|
|
46
|
+
` return spec;`,
|
|
47
|
+
` return { ...spec, doc: parseAutomationDoc(spec.script), filePath: automationPath(spec.name) };`
|
|
48
|
+
);
|
|
49
|
+
expected = replaceOnce(
|
|
50
|
+
expected,
|
|
51
|
+
` return { name, script: fs.readFileSync(p, "utf8") };`,
|
|
52
|
+
` const script = fs.readFileSync(p, "utf8");\n return { name, script, doc: parseAutomationDoc(script), filePath: p };`
|
|
53
|
+
);
|
|
54
|
+
expected = replaceOnce(
|
|
55
|
+
expected,
|
|
56
|
+
` return { name, script: parsed.script };`,
|
|
57
|
+
` return {\n name,\n script: parsed.script,\n doc: parseAutomationDoc(parsed.script || ""),\n filePath: legacy,\n };`
|
|
58
|
+
);
|
|
59
|
+
return expected;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("patchFile transcript-derived mangling regressions", () => {
|
|
63
|
+
async function expectExactEditOrSafeRejection(
|
|
64
|
+
name: string,
|
|
65
|
+
buildExpected: (source: string) => string
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const fixture = loadFixture(name);
|
|
68
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "patch-transcript-"));
|
|
69
|
+
const testFile = path.join(dir, `${name}.ts`);
|
|
70
|
+
fs.writeFileSync(testFile, fixture.sourceBefore);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const result = await boundPatch(testFile, fixture.patch);
|
|
74
|
+
const actual = fs.readFileSync(testFile, "utf8");
|
|
75
|
+
if (result.includes("❌ Patch failed")) {
|
|
76
|
+
expect(actual).toBe(fixture.sourceBefore);
|
|
77
|
+
} else {
|
|
78
|
+
expect(actual).toBe(buildExpected(fixture.sourceBefore));
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Regression from task 1785613689: malformed hunk counts previously caused
|
|
86
|
+
// `diff.applyPatch` to move the catch and duplicate entries.push.
|
|
87
|
+
it(
|
|
88
|
+
"agent-sync-fs patch applies exactly or rejects without changing the file",
|
|
89
|
+
async () => {
|
|
90
|
+
await expectExactEditOrSafeRejection(
|
|
91
|
+
"agent-sync-fs-nested-catch",
|
|
92
|
+
expectedAgentSyncFs
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
it("automation patch applies exactly or rejects without changing the file", async () => {
|
|
98
|
+
await expectExactEditOrSafeRejection(
|
|
99
|
+
"automation-functions-crossed",
|
|
100
|
+
expectedAutomation
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Regression from task 1785784065: the malformed final hunk's declared
|
|
105
|
+
// counts omit the remainder of sdk.log. The current autofix reconstructs
|
|
106
|
+
// the intended edit exactly instead of crossing the two adjacent blocks.
|
|
107
|
+
it(
|
|
108
|
+
"fruit-ninja confirmed-retirement patch applies exactly",
|
|
109
|
+
async () => {
|
|
110
|
+
const fixture = loadFixture("fruit-ninja-confirmed-retirement");
|
|
111
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "patch-transcript-"));
|
|
112
|
+
const testFile = path.join(dir, "fruitNinjaPrecision.ts");
|
|
113
|
+
fs.writeFileSync(testFile, fixture.sourceBefore);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const result = await boundPatch(testFile, fixture.patch);
|
|
117
|
+
expect(result).not.toContain("❌ Patch failed");
|
|
118
|
+
const actual = fs.readFileSync(testFile, "utf8");
|
|
119
|
+
expect(actual).toBe(fixture.expectedAfter);
|
|
120
|
+
} finally {
|
|
121
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
});
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
Base64ImageProcessor,
|
|
3
3
|
globalBase64ImageDetector,
|
|
4
4
|
} from "../../src/processors/Base64ImageDetector";
|
|
5
|
-
import { Message } from "../../src/clients/types";
|
|
5
|
+
import { Message, MessageContent } from "../../src/clients/types";
|
|
6
6
|
import { ToolsService } from "../../src/services/Tools";
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
|
|
@@ -185,6 +185,41 @@ describe("Base64ImageDetector", () => {
|
|
|
185
185
|
});
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
it("should process tool messages with JSON string containing an ARRAY of image_url parts", () => {
|
|
189
|
+
// Computer-use screenshot tools and loadWebpage(mode:"screenshot") return
|
|
190
|
+
// an array of parts, not a single object. This shape was previously missed
|
|
191
|
+
// by the single-object check, leaving the model to see a raw base64 string.
|
|
192
|
+
const imageDataUrl =
|
|
193
|
+
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=";
|
|
194
|
+
|
|
195
|
+
const toolResponseJson = JSON.stringify([
|
|
196
|
+
{
|
|
197
|
+
type: "image_url",
|
|
198
|
+
image_url: { url: imageDataUrl },
|
|
199
|
+
},
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
const originalMessages: Message[] = [];
|
|
203
|
+
const modifiedMessages: Message[] = [
|
|
204
|
+
{
|
|
205
|
+
role: "tool",
|
|
206
|
+
content: toolResponseJson,
|
|
207
|
+
tool_call_id: "call_arr",
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
const processor = detector.createProcessor();
|
|
212
|
+
processor(originalMessages, modifiedMessages);
|
|
213
|
+
|
|
214
|
+
// Tool message content should become a real multimodal array so the model
|
|
215
|
+
// actually receives the image instead of a base64 JSON string.
|
|
216
|
+
expect(Array.isArray(modifiedMessages[0].content)).toBe(true);
|
|
217
|
+
const content = modifiedMessages[0].content as any[];
|
|
218
|
+
expect(content).toHaveLength(1);
|
|
219
|
+
expect(content[0].type).toBe("image_url");
|
|
220
|
+
expect(content[0].image_url.url).toBe(imageDataUrl);
|
|
221
|
+
});
|
|
222
|
+
|
|
188
223
|
it("should process tool messages with plain base64 string", () => {
|
|
189
224
|
const validPngBase64 =
|
|
190
225
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
package/ts_build/package.json
CHANGED
|
@@ -21,6 +21,10 @@ export interface ToolCallEvent {
|
|
|
21
21
|
toolCall: ToolCall;
|
|
22
22
|
functionResp: any;
|
|
23
23
|
}
|
|
24
|
+
type ChatCompletion = Parameters<GenericClient["createChatCompletion"]>[0];
|
|
25
|
+
type CachedChatCompletion = {
|
|
26
|
+
messages: ChatCompletion["messages"];
|
|
27
|
+
} & Partial<Omit<ChatCompletion, "messages">>;
|
|
24
28
|
export interface ObservationSource {
|
|
25
29
|
label: string;
|
|
26
30
|
start(emit: (datum: unknown) => void): void | (() => void) | Promise<void | (() => void)>;
|
|
@@ -76,6 +80,7 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
76
80
|
protected taskBreakdown: string;
|
|
77
81
|
protected summaries: string[];
|
|
78
82
|
protected currentTaskId: string | null;
|
|
83
|
+
parentTaskId: string | null;
|
|
79
84
|
agentEvents: EventService;
|
|
80
85
|
eventTypes: {
|
|
81
86
|
newThread: string;
|
|
@@ -119,6 +124,8 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
119
124
|
getModel(): string;
|
|
120
125
|
setModel(value: string): void;
|
|
121
126
|
getProvider(): string;
|
|
127
|
+
getParentTaskId(): string | null;
|
|
128
|
+
setParentTaskId(value: string | null): void;
|
|
122
129
|
setProvider(value: keyof typeof Clients.clients): void;
|
|
123
130
|
setReasoningEffort(effort: CompletionOptions["reasoning_effort"]): void;
|
|
124
131
|
getReasoningEffort(): CompletionOptions["reasoning_effort"];
|
|
@@ -146,7 +153,7 @@ export declare abstract class BaseAgent implements IAgent {
|
|
|
146
153
|
totalCacheReadTokens: number;
|
|
147
154
|
totalCacheWriteTokens: number;
|
|
148
155
|
};
|
|
149
|
-
protected createAgentCompletion(params:
|
|
156
|
+
protected createAgentCompletion(params: CachedChatCompletion, options?: {
|
|
150
157
|
interruptValue?: CompletionResponse;
|
|
151
158
|
}): Promise<CompletionResponse>;
|
|
152
159
|
startNewThread(messages: Message[]): void;
|
|
@@ -44,6 +44,7 @@ class BaseAgent {
|
|
|
44
44
|
taskBreakdown = "";
|
|
45
45
|
summaries = [];
|
|
46
46
|
currentTaskId = null;
|
|
47
|
+
parentTaskId = null;
|
|
47
48
|
agentEvents = new EventService_1.EventService();
|
|
48
49
|
eventTypes = {
|
|
49
50
|
newThread: "new_thread",
|
|
@@ -189,6 +190,12 @@ class BaseAgent {
|
|
|
189
190
|
getProvider() {
|
|
190
191
|
return this.provider;
|
|
191
192
|
}
|
|
193
|
+
getParentTaskId() {
|
|
194
|
+
return this.parentTaskId;
|
|
195
|
+
}
|
|
196
|
+
setParentTaskId(value) {
|
|
197
|
+
this.parentTaskId = value ?? null;
|
|
198
|
+
}
|
|
192
199
|
setProvider(value) {
|
|
193
200
|
this.provider = value;
|
|
194
201
|
this.client = null;
|
|
@@ -357,7 +364,20 @@ class BaseAgent {
|
|
|
357
364
|
}
|
|
358
365
|
async createAgentCompletion(params, options = {}) {
|
|
359
366
|
const { interruptValue } = options;
|
|
360
|
-
const
|
|
367
|
+
const defaultedParams = {
|
|
368
|
+
model: this.getModel(),
|
|
369
|
+
tools: this.getEnabledTools(),
|
|
370
|
+
tool_choice: "auto",
|
|
371
|
+
long_ttl_cache: this.runTime() > 300_000,
|
|
372
|
+
...(this.reasoningEffort !== undefined && {
|
|
373
|
+
reasoning_effort: this.reasoningEffort,
|
|
374
|
+
}),
|
|
375
|
+
...(this.summarizeReasoning !== undefined && {
|
|
376
|
+
reasoning_summary: this.summarizeReasoning,
|
|
377
|
+
}),
|
|
378
|
+
...params,
|
|
379
|
+
};
|
|
380
|
+
const callPromise = this.getClient().createChatCompletion(defaultedParams);
|
|
361
381
|
const response = interruptValue
|
|
362
382
|
? await this.makeInterruptible(callPromise, interruptValue)
|
|
363
383
|
: await callPromise;
|
|
@@ -400,6 +420,7 @@ class BaseAgent {
|
|
|
400
420
|
const { functionResp, toolMessages } = await this.makeInterruptible(this.tools.callTool(toolCall, this.getEnabledToolNames(), {
|
|
401
421
|
caller: this,
|
|
402
422
|
taskId: this.currentTaskId,
|
|
423
|
+
parentTaskId: this.parentTaskId ?? undefined,
|
|
403
424
|
}), interruptResult);
|
|
404
425
|
this.agentEvents.emit(this.eventTypes.toolUsed, {
|
|
405
426
|
toolCall,
|
|
@@ -409,6 +430,12 @@ class BaseAgent {
|
|
|
409
430
|
}
|
|
410
431
|
logMessages(messages) {
|
|
411
432
|
for (const message of messages) {
|
|
433
|
+
const reasoningSummary = message.reasoning_summary;
|
|
434
|
+
if (message.role === "assistant" && reasoningSummary) {
|
|
435
|
+
this.agentEvents.emit(this.eventTypes.agentSay, {
|
|
436
|
+
message: `💭 ${reasoningSummary}`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
412
439
|
if (message.role === "assistant" && message.content) {
|
|
413
440
|
this.agentEvents.emit(this.eventTypes.agentSay, {
|
|
414
441
|
message: message.content,
|
|
@@ -989,7 +1016,15 @@ class BaseAgent {
|
|
|
989
1016
|
const response = await this.createAgentCompletion({
|
|
990
1017
|
model,
|
|
991
1018
|
messages: taskBreakdownMessages,
|
|
992
|
-
|
|
1019
|
+
tools: this.getEnabledTools(),
|
|
1020
|
+
tool_choice: "auto",
|
|
1021
|
+
long_ttl_cache: this.runTime() > 300_000,
|
|
1022
|
+
...(this.reasoningEffort !== undefined && {
|
|
1023
|
+
reasoning_effort: this.reasoningEffort,
|
|
1024
|
+
}),
|
|
1025
|
+
...(this.summarizeReasoning !== undefined && {
|
|
1026
|
+
reasoning_summary: this.summarizeReasoning,
|
|
1027
|
+
}),
|
|
993
1028
|
});
|
|
994
1029
|
this.taskBreakdown = response.choices[0].message.content;
|
|
995
1030
|
this.log(`task breakdown cost: ${response.usd_cost}`);
|
|
@@ -1021,7 +1056,6 @@ class BaseAgent {
|
|
|
1021
1056
|
},
|
|
1022
1057
|
];
|
|
1023
1058
|
const response = await this.createAgentCompletion({
|
|
1024
|
-
model,
|
|
1025
1059
|
messages: compressMessagesPayload,
|
|
1026
1060
|
});
|
|
1027
1061
|
const summaries = response.choices.map((c) => c.message.content);
|