From 94ddfe10648972e21b0bffe4df04df4841702f64 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 08:04:12 -0700 Subject: [PATCH] FN-7274: document workflow tools in Fusion skill Document the workflow-authoring tool surface in the packaged Fusion skill references. - Add workflow tools to the generated Fusion skill category and capability tables. - Teach the skill sync script to read workflow tool specs from extension and engine agent tool sources. - Cover workflow tool documentation and cache inputs with sync tests. - Add a changeset for the published CLI skill update. Files changed: .changeset/fn-7274-workflow-skill-tools.md | 7 + packages/cli/skill/fusion/SKILL.md | 1 + .../cli/skill/fusion/references/engine-tools.md | 4 +- .../cli/skill/fusion/references/extension-tools.md | 83 +++++++++- .../skill/fusion/references/fusion-capabilities.md | 8 + packages/cli/src/__tests__/skill-sync.test.ts | 60 ++++++- scripts/__tests__/skill-sync-cache.test.mjs | 5 + scripts/sync-fusion-skill-tools.mjs | 172 ++++++++++++++++++--- 8 files changed, 312 insertions(+), 28 deletions(-) Fusion-Task-Id: FN-7274 Fusion-Task-Lineage: e1cd6bd8-aecc-49ca-96ed-a937a15a18a4 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7274-workflow-skill-tools.md | 7 + packages/cli/skill/fusion/SKILL.md | 1 + .../skill/fusion/references/engine-tools.md | 4 +- .../fusion/references/extension-tools.md | 83 ++++++++- .../fusion/references/fusion-capabilities.md | 8 + packages/cli/src/__tests__/skill-sync.test.ts | 60 +++++- scripts/__tests__/skill-sync-cache.test.mjs | 5 + scripts/sync-fusion-skill-tools.mjs | 172 ++++++++++++++++-- 8 files changed, 312 insertions(+), 28 deletions(-) create mode 100644 .changeset/fn-7274-workflow-skill-tools.md diff --git a/.changeset/fn-7274-workflow-skill-tools.md b/.changeset/fn-7274-workflow-skill-tools.md new file mode 100644 index 0000000000..bc5858843a --- /dev/null +++ b/.changeset/fn-7274-workflow-skill-tools.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Document workflow-authoring tools in the packaged Fusion skill. +category: fix +dev: Syncs workflow extension registrations into generated skill references and capability tables. diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 92293fc9ed..516f87dad3 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -28,6 +28,7 @@ Mission → Milestone → Slice → Feature → Task **Tool categories:** - **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan` +- **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` - **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 5c53e0e14c..b33bfb50f8 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -1,11 +1,11 @@ # Engine Session-Scoped Tools -These tools are **not** part of the user-invokable extension surface. They are injected by the engine at runtime for specific agent session types. +This reference documents tools injected by the engine at runtime for specific agent session types. Some shared workflow tools are also part of the public extension surface; use `references/extension-tools.md` as the canonical user-invokable extension reference and this page for runtime agent-role availability. - Source files: `packages/engine/src/agent-tools.ts`, `triage.ts`, `executor.ts`, `merger.ts`, `agent-heartbeat.ts` - Availability: only when the engine creates a session for the matching agent role - Runtime contract: engine sessions now forward requested skill names (`skillSelection.requestedSkillNames`) into the generic runtime `skills` field so non-pi runtimes can still receive Fusion skill intent. -- Important: do not tell users to call these directly from the generic extension tool list +- Important: do not tell users to call runtime-only tools directly from the generic extension tool list ## Shared runtime tools (`agent-tools.ts`) diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 8c1721d2ae..51555bcc91 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -18,7 +18,7 @@ Create a new task on the Fusion task board. The task enters the planning column | `depends` | array | — | Task IDs this depends on (e.g. ['FN-001', 'FN-002']) | | `agentId` | string | — | Agent ID to assign this task to (e.g. 'agent-abc123') | | `priority` | string(enum) | — | Task priority (low, normal, high, urgent) | -| `workflow_id` | string | — | Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). | +| `workflow_id` | string | — | Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs. | ### fn_task_update @@ -33,7 +33,7 @@ Update fields on an existing task. Supports modifying the title, description, de | `agentId` | union | — | Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123') | | `nodeId` | union | — | Node ID override for this task, or null to clear | | `priority` | string(enum) | — | Task priority (low, normal, high, urgent) | -| `workflow_id` | union | — | Workflow ID to select for this task (e.g. 'WF-003' or 'builtin:coding'), | +| `workflow_id` | union | — | Workflow ID to select for this task (e.g. 'WF-003' or 'builtin:coding'), or null to clear the workflow selection and revert to the project default. Use fn_workflow_list to discover valid IDs. | ### fn_task_list @@ -136,6 +136,81 @@ Create a task via AI-guided planning mode — interactive conversation to refine | `description` | string | — | Initial plan description (optional) — the AI will ask clarifying questions if not provided | | `baseBranch` | string | — | Optional base branch for the task created from this planning session | +## Workflow Tools + +### fn_workflow_list + +List built-in and custom Fusion workflow definitions available in this project. + +No parameters. + +### fn_workflow_get + +Fetch a Fusion workflow definition by ID, including its resolved workflow IR. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `workflow_id` | string | ✓ | The workflow definition ID to fetch (e.g. 'WF-003', or a 'builtin:*' id). Use fn_workflow_list to discover available IDs. | + +### fn_workflow_create + +Create a custom Fusion workflow definition from a validated workflow IR. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | ✓ | Workflow name (required, non-empty). | +| `description` | string | — | Optional human-readable description. | +| `ir` | unknown | ✓ | Workflow graph (intermediate representation). Validated server-side; a malformed graph is rejected. | +| `layout` | record | — | Optional node layout map keyed by node id. | +| `confirm_policy_escalation` | boolean | — | Set true to confirm binding a column to an agent whose permission policy is broader (more privileged) than the project default. Required when such a binding is present; the create is otherwise rejected naming the offending column. | + +### fn_workflow_update + +Update a custom Fusion workflow definition's metadata, IR, or layout. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `workflow_id` | string | ✓ | The workflow definition ID to update (built-ins cannot be edited). | +| `name` | string | — | New name. | +| `description` | string | — | New description. | +| `ir` | unknown | — | Replacement workflow graph (validated server-side). | +| `layout` | record | — | Replacement node layout map. | +| `rehome_to` | string | — | When an IR update removes a column that still holds cards, supply the column id to re-home those occupants into. Required to resolve an OccupiedColumns conflict; the target must exist in the new IR. | +| `confirm_policy_escalation` | boolean | — | Set true to confirm binding a column to an agent whose permission policy is broader (more privileged) than the project default. Required when such a binding is present; the update is otherwise rejected naming the offending column. | + +### fn_workflow_delete + +Delete a custom Fusion workflow definition; built-in workflows are protected. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `workflow_id` | string | ✓ | The workflow definition ID to delete (built-ins cannot be deleted). | + +### fn_workflow_settings + +Read or write per-project values for a workflow's declared settings. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | union | ✓ | `get` reads the stored setting VALUES plus the engine-effective values for the workflow; `set` writes values (requires `values`). | +| `workflow_id` | string | ✓ | The workflow whose setting VALUES to read/write (e.g. 'WF-003', or a 'builtin:*' id). Values are scoped per (workflow, project). Built-in workflow VALUES are writable even though built-in DECLARATIONS are not (declarations are edited via the workflow IR's `settings`). Values are validated against THIS workflow's declared settings (use fn_workflow_get to inspect them). | +| `values` | record | — | For action='set': a map of settingId → value to write. A `null` value DELETES the override (null-as-delete). Each value is validated against the named workflow's declaration; on ANY rejection (unknown-setting/type-mismatch/enum-violation/no-settings-defined) nothing is persisted and the typed rejection list is returned. | + +### fn_trait_list + +List column traits available when authoring Fusion workflow IR columns. + +No parameters. + +### fn_workflow_select + +Assign a workflow definition to a task by workflow ID. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `workflow_id` | string | ✓ | The workflow definition ID to select (e.g. 'WF-003', or a 'builtin:*' id). Use fn_workflow_list to discover available IDs. | +| `task_id` | string | — | Task to assign the workflow to. Defaults to the current task. | + ## GitHub Tools ### fn_task_import_github @@ -456,8 +531,8 @@ Create a new task and assign it to a specific agent for execution. The task goes |-----------|------|----------|-------------| | `agent_id` | string | ✓ | The agent ID to delegate work to | | `description` | string | ✓ | What needs to be done | -| `dependencies` | array | — | Task IDs this new task depends on (e.g. [\"KB-001\"] | -| `workflow_id` | string | — | Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). | +| `dependencies` | array | — | Task IDs this new task depends on (e.g. ["KB-001"] | +| `workflow_id` | string | — | Workflow ID to select for the new task (e.g. 'WF-003' or 'builtin:coding'). Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs. | | `override` | boolean | — | Set true to bypass executor-role assignment policy | ### fn_agent_show diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 7690f866f9..fc5ea125c7 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -12,6 +12,14 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | Tool | Purpose | |------|---------| +| `fn_workflow_list` | List built-in and custom Fusion workflow definitions available in this project. | +| `fn_workflow_get` | Fetch a Fusion workflow definition by ID, including its resolved workflow IR. | +| `fn_workflow_create` | Create a custom Fusion workflow definition from a validated workflow IR. | +| `fn_workflow_update` | Update a custom Fusion workflow definition's metadata, IR, or layout. | +| `fn_workflow_delete` | Delete a custom Fusion workflow definition; built-in workflows are protected. | +| `fn_workflow_settings` | Read or write per-project values for a workflow's declared settings. | +| `fn_trait_list` | List column traits available when authoring Fusion workflow IR columns. | +| `fn_workflow_select` | Assign a workflow definition to a task by workflow ID. | | `fn_task_create` | Create a new task on the Fusion task board. The task enters the planning column where the AI planning agent will plan it into a full prompt with steps, file scope, and acceptance criteria. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs. | | `fn_task_update` | Update fields on an existing task. Supports modifying the title, description, dependencies, assigned agent, priority, and workflow_id after task creation. Set workflow_id to a workflow ID to select it, or null to clear the workflow selection. | | `fn_task_list` | List all tasks on the Fusion board, grouped by column. | diff --git a/packages/cli/src/__tests__/skill-sync.test.ts b/packages/cli/src/__tests__/skill-sync.test.ts index fec82aa2b5..4eed2fab06 100644 --- a/packages/cli/src/__tests__/skill-sync.test.ts +++ b/packages/cli/src/__tests__/skill-sync.test.ts @@ -9,6 +9,7 @@ const cliRoot = resolve(__dirname, "../.."); const repoRoot = resolve(cliRoot, "../.."); const skillDir = resolve(cliRoot, "skill/fusion"); const extensionPath = resolve(cliRoot, "src/extension.ts"); +const engineAgentToolsPath = resolve(repoRoot, "packages/engine/src/agent-tools.ts"); const EXT_TOOLS_BEGIN = ""; @@ -186,6 +187,36 @@ function splitTopLevelProperties(objectBody: string): string[] { return props; } +function getWorkflowExtensionSpecBlocks(): Array<{ name: string; paramsRef: string }> { + const src = readFileSync(extensionPath, "utf-8"); + const specStart = src.indexOf("const workflowExtensionToolSpecs"); + if (specStart === -1) return []; + const specEnd = src.indexOf("// ── Extension entry point", specStart); + const body = src.slice(specStart, specEnd === -1 ? undefined : specEnd); + const blocks: Array<{ name: string; paramsRef: string }> = []; + + for (const match of body.matchAll(/\{[\s\S]*?name:\s*"(fn_[a-z_]+)"[\s\S]*?parameters:\s*([A-Za-z_][A-Za-z0-9_]*)[\s\S]*?\}/g)) { + blocks.push({ name: match[1], paramsRef: match[2] }); + } + + return blocks; +} + +function getExportedTypeObjectParamNames(exportName: string): string[] { + const src = readFileSync(engineAgentToolsPath, "utf-8"); + const exportStart = src.indexOf(`export const ${exportName} = Type.Object(`); + if (exportStart === -1) return []; + const braceStart = src.indexOf("{", exportStart); + if (braceStart === -1) return []; + const braceEnd = findMatchingBrace(src, braceStart); + const body = src.slice(braceStart + 1, braceEnd); + + return splitTopLevelProperties(body) + .map((prop) => prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/)?.[1]) + .filter((value): value is string => Boolean(value)) + .sort(); +} + function getRegisterToolBlocks(): Array<{ name: string; block: string }> { const src = readFileSync(extensionPath, "utf-8"); const blocks: Array<{ name: string; block: string }> = []; @@ -213,13 +244,17 @@ function getRegisterToolBlocks(): Array<{ name: string; block: string }> { * from the extension source code. */ function getExtensionToolNames(): string[] { - return getRegisterToolBlocks() - .map((entry) => entry.name) - .sort(); + return [ + ...getWorkflowExtensionSpecBlocks().map((entry) => entry.name), + ...getRegisterToolBlocks().map((entry) => entry.name), + ].sort(); } function getExtensionToolParamNames(): Map { const result = new Map(); + for (const { name, paramsRef } of getWorkflowExtensionSpecBlocks()) { + result.set(name, getExportedTypeObjectParamNames(paramsRef)); + } for (const { name, block } of getRegisterToolBlocks()) { const paramsStart = block.indexOf("parameters:"); if (paramsStart === -1) { @@ -383,6 +418,15 @@ describe("Skill-Extension Sync", () => { expect(missingFromSkill).toEqual([]); }); + it("SKILL.md documents workflow authoring tools in a dedicated category", () => { + const skillMd = readFileSync(resolve(skillDir, "SKILL.md"), "utf-8"); + const workflowCategory = skillMd.match(/- \*\*Workflow tools\*\* — ([^\n]+)/)?.[1] ?? ""; + + expect(workflowCategory).toContain("`fn_workflow_create`"); + expect(workflowCategory).toContain("`fn_workflow_update`"); + expect(workflowCategory).toContain("`fn_trait_list`"); + }); + it("fusion-capabilities.md tool table includes all registered tools", () => { const extensionTools = getExtensionToolNames(); const capTools = getCapabilitiesToolNames(); @@ -406,6 +450,16 @@ describe("Skill-Extension Sync", () => { expect(doc).toContain(EXT_TOOLS_END); }); + it("extension-tools.md includes workflow tool reference sections", () => { + const doc = readFileSync(resolve(skillDir, "references/extension-tools.md"), "utf-8"); + const workflowSection = doc.match(/## Workflow Tools\n([\s\S]*?)\n## GitHub Tools/)?.[1] ?? ""; + + expect(workflowSection).toContain("### fn_workflow_create"); + expect(workflowSection).toContain("### fn_workflow_settings"); + expect(workflowSection).toContain("### fn_trait_list"); + expect(workflowSection).toContain("| `ir` | unknown | ✓ |"); + }); + it("engine-tools.md documents all engine session-scoped tools", () => { const engineTools = getEngineSessionToolNames(); const documented = getDocumentedEngineToolNames(); diff --git a/scripts/__tests__/skill-sync-cache.test.mjs b/scripts/__tests__/skill-sync-cache.test.mjs index c3c0dbade9..90ac10ecee 100644 --- a/scripts/__tests__/skill-sync-cache.test.mjs +++ b/scripts/__tests__/skill-sync-cache.test.mjs @@ -47,6 +47,11 @@ function fakeGit(shaByPath, { dirty = [] } = {}) { const baseShas = Object.fromEntries(SKILL_SYNC_INPUT_PATHS.map((p, i) => [p, `sha${i}`])); +test("skill sync cache watches extension and engine workflow tool sources", () => { + assert.ok(SKILL_SYNC_INPUT_PATHS.includes("packages/cli/src/extension.ts")); + assert.ok(SKILL_SYNC_INPUT_PATHS.includes("packages/engine/src/agent-tools.ts")); +}); + test("recordSkillSyncCheckPass then isSkillSyncCheckCached returns true on unchanged inputs", () => { withRoot((root) => { const deps = { gitFn: fakeGit(baseShas), readFn: () => Buffer.from("") }; diff --git a/scripts/sync-fusion-skill-tools.mjs b/scripts/sync-fusion-skill-tools.mjs index 3d04a62c0a..c54cbd0959 100644 --- a/scripts/sync-fusion-skill-tools.mjs +++ b/scripts/sync-fusion-skill-tools.mjs @@ -26,6 +26,7 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts"); +const engineAgentToolsPath = resolve(repoRoot, "packages/engine/src/agent-tools.ts"); const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md"); const extensionToolsPath = resolve( repoRoot, @@ -51,6 +52,7 @@ const capabilitiesPath = resolve( /** Repo-relative input paths whose content determines the --check result. */ export const SKILL_SYNC_INPUT_PATHS = [ "packages/cli/src/extension.ts", + "packages/engine/src/agent-tools.ts", "packages/cli/skill/fusion/SKILL.md", "packages/cli/skill/fusion/references/extension-tools.md", "packages/cli/skill/fusion/references/fusion-capabilities.md", @@ -129,9 +131,10 @@ const CAP_TABLE_BEGIN = ""; const CAP_TABLE_END = ""; -const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Goal", "Agent", "Skills", "Insight", "Other"]; +const CATEGORY_ORDER = ["Task", "Workflow", "GitHub", "Mission", "Goal", "Agent", "Skills", "Insight", "Other"]; const CATEGORY_LABELS = { Task: "Task tools", + Workflow: "Workflow tools", GitHub: "GitHub tools", Mission: "Mission tools", Goal: "Goal tools", @@ -143,6 +146,7 @@ const CATEGORY_LABELS = { const CATEGORY_HEADERS = { Task: "## Task Tools", + Workflow: "## Workflow Tools", GitHub: "## GitHub Tools", Mission: "## Mission Tools", Goal: "## Goal Tools", @@ -155,6 +159,11 @@ const CATEGORY_HEADERS = { function categorize(name) { if (name.includes("github")) return "GitHub"; if (name.startsWith("fn_task_")) return "Task"; + /* + * FNXC:SkillSync 2026-06-30-00:00: + * FN-7245 made workflow authoring tools part of the public pi extension surface. Keep fn_workflow_* plus the trait vocabulary grouped as Workflow so generated skill docs teach agents to create, update, inspect, configure, and select workflows instead of hiding those tools under Other. + */ + if (name.startsWith("fn_workflow_") || name === "fn_trait_list") return "Workflow"; if ( name.startsWith("fn_mission_") || name.startsWith("fn_milestone_") || @@ -189,7 +198,7 @@ function normalizeWhitespace(text) { return text.replace(/\s+/g, " ").trim(); } -function findMatchingBrace(source, openIndex) { +function findMatchingDelimiter(source, openIndex, openChar, closeChar) { let depth = 0; let inSingle = false; let inDouble = false; @@ -253,14 +262,22 @@ function findMatchingBrace(source, openIndex) { continue; } - if (char === "{") depth++; - if (char === "}") { + if (char === openChar) depth++; + if (char === closeChar) { depth--; if (depth === 0) return i; } } - throw new Error("Unbalanced braces while parsing extension.ts"); + throw new Error(`Unbalanced ${openChar}${closeChar} while parsing extension.ts`); +} + +function findMatchingBrace(source, openIndex) { + return findMatchingDelimiter(source, openIndex, "{", "}"); +} + +function findMatchingBracket(source, openIndex) { + return findMatchingDelimiter(source, openIndex, "[", "]"); } function splitTopLevelProperties(objectBody) { @@ -372,7 +389,10 @@ function mapType(raw) { if (/^Type\.Boolean\(/.test(value)) return "boolean"; if (/^Type\.Array\(/.test(value)) return "array"; if (/^Type\.Union\(/.test(value)) return "union"; + if (/^Type\.Record\(/.test(value)) return "record"; + if (/^Type\.Unknown\(/.test(value)) return "unknown"; if (/^StringEnum\(/.test(value)) return "string(enum)"; + if (/^Type\.Literal\(/.test(value)) return "literal"; if (/^Type\.Null\(/.test(value)) return "null"; return "unknown"; } @@ -387,8 +407,77 @@ function parseParameters(block) { const braceStart = block.indexOf("{", objectStart); if (braceStart === -1) return []; const braceEnd = findMatchingBrace(block, braceStart); - const body = block.slice(braceStart + 1, braceEnd); + return parseTypeObjectParameterBody(block.slice(braceStart + 1, braceEnd)); +} +function slicePropertyExpression(source, propertyName) { + const propertyStart = source.indexOf(`${propertyName}:`); + if (propertyStart === -1) return ""; + let start = propertyStart + propertyName.length + 1; + while (/\s/.test(source[start] ?? "")) start++; + + let depthParen = 0; + let depthBrace = 0; + let depthBracket = 0; + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let escaped = false; + + for (let i = start; i < source.length; i++) { + const ch = source[i]; + + if (inSingle || inDouble || inTemplate) { + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (inSingle && ch === "'") inSingle = false; + else if (inDouble && ch === '"') inDouble = false; + else if (inTemplate && ch === "`") inTemplate = false; + continue; + } + + if (ch === "'") { + inSingle = true; + continue; + } + if (ch === '"') { + inDouble = true; + continue; + } + if (ch === "`") { + inTemplate = true; + continue; + } + + if (ch === "(") depthParen++; + else if (ch === ")") depthParen--; + else if (ch === "{") depthBrace++; + else if (ch === "}") depthBrace--; + else if (ch === "[") depthBracket++; + else if (ch === "]") depthBracket--; + else if (ch === "," && depthParen === 0 && depthBrace === 0 && depthBracket === 0) { + return source.slice(start, i); + } + } + + return source.slice(start); +} + +function extractParameterDescription(rawValue) { + const descriptionExpression = slicePropertyExpression(rawValue, "description"); + const concatenated = normalizeWhitespace(parseStringLiterals(descriptionExpression).join(" ")); + if (concatenated) return concatenated; + const inlineDescription = rawValue.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + return inlineDescription ? normalizeWhitespace(inlineDescription[1].replace(/\\n/g, " ")) : ""; +} + +function parseTypeObjectParameterBody(body) { const params = []; for (const prop of splitTopLevelProperties(body)) { const match = prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/); @@ -399,24 +488,65 @@ function parseParameters(block) { ? rawValue.trim().replace(/^Type\.Optional\(/, "").replace(/\)\s*$/, "") : rawValue.trim(); - const descMatch = rawValue.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); - const description = descMatch - ? normalizeWhitespace(descMatch[1].replace(/\\n/g, " ")) - : ""; - params.push({ name, type: mapType(inner), required: !optional, - description, + description: extractParameterDescription(rawValue), }); } return params; } -function extractTools(source) { +function getExportedTypeObjectParameters(source, exportName) { + const exportStart = source.indexOf(`export const ${exportName} = Type.Object(`); + if (exportStart === -1) return []; + const braceStart = source.indexOf("{", exportStart); + if (braceStart === -1) return []; + const braceEnd = findMatchingBrace(source, braceStart); + return parseTypeObjectParameterBody(source.slice(braceStart + 1, braceEnd)); +} + +function extractWorkflowExtensionSpecTools(source, engineSource) { + const specStart = source.indexOf("const workflowExtensionToolSpecs"); + if (specStart === -1) return []; + const equalsStart = source.indexOf("=", specStart); + const arrayStart = source.indexOf("[", equalsStart); + if (arrayStart === -1) return []; + const arrayEnd = findMatchingBracket(source, arrayStart); + const body = source.slice(arrayStart + 1, arrayEnd); const tools = []; + + let fromIndex = 0; + while (true) { + const objectStart = body.indexOf("{", fromIndex); + if (objectStart === -1) break; + const objectEnd = findMatchingBrace(body, objectStart); + const block = body.slice(objectStart, objectEnd + 1); + const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/); + if (nameMatch) { + const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + const descriptionMatch = block.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + const paramsRefMatch = block.match(/parameters:\s*([A-Za-z_][A-Za-z0-9_]*)/); + tools.push({ + name: nameMatch[1], + label: labelMatch ? labelMatch[1] : "", + description: descriptionMatch ? normalizeWhitespace(descriptionMatch[1]) : "", + parameters: paramsRefMatch + ? getExportedTypeObjectParameters(engineSource, paramsRefMatch[1]) + : [], + }); + } + fromIndex = objectEnd + 1; + } + + return tools; +} + +function extractTools(source, engineSource = "") { + const tools = extractWorkflowExtensionSpecTools(source, engineSource); + const seen = new Set(tools.map((tool) => tool.name)); const registerToken = "pi.registerTool("; let fromIndex = 0; @@ -430,11 +560,14 @@ function extractTools(source) { const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/); if (nameMatch) { const name = nameMatch[1]; - const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); - const label = labelMatch ? labelMatch[1] : ""; - const description = extractDescription(block); - const parameters = parseParameters(block); - tools.push({ name, label, description, parameters }); + if (!seen.has(name)) { + const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); + const label = labelMatch ? labelMatch[1] : ""; + const description = extractDescription(block); + const parameters = parseParameters(block); + tools.push({ name, label, description, parameters }); + seen.add(name); + } } fromIndex = braceEnd + 1; @@ -538,7 +671,8 @@ function main() { const checkOnly = process.argv.includes("--check"); const extensionSource = readFileSync(extensionPath, "utf-8"); - const tools = extractTools(extensionSource); + const engineSource = readFileSync(engineAgentToolsPath, "utf-8"); + const tools = extractTools(extensionSource, engineSource); const files = [ {