diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | init.lua | 6 | ||||
| -rw-r--r-- | spec/test_luatool.lua | 40 | ||||
| -rw-r--r-- | subagents/jobs.lua | 15 | ||||
| -rw-r--r-- | subagents/workflow.lua | 30 |
5 files changed, 90 insertions, 8 deletions
@@ -140,7 +140,7 @@ top-level `output = ["id", ...]` array names, in that order. The Lua workflow API handles dynamic branching and fan-out. `subagents.lua` starts model-authored workflows in a restricted environment and returns a session-scoped workflow ID immediately. Work continues on Pantograph's event -loop; completion wakes the primary, and later calls can inspect immutable +loop; completion wakes the primary, and later calls can inspect field-read-only records through `subagents.workflows[id]`, including named child status and outputs. Every `ctx:agent` needs a workflow-unique `name`, and the callback must return the workflow's string result. @@ -148,7 +148,10 @@ return the workflow's string result. The same read-only workflow records are exposed to other extensions as `panto.ext.workflows`. Extensions can iterate this table or look up an ID; each record has `id`, `status`, `result`, `error`, and `agents`, and a `status` of -`running` identifies in-flight background work. +`running` identifies in-flight background work. Fields are read-only, but +`workflow:cancel()` requests cancellation for every running agent and +`workflow.agents[name]:cancel()` requests cancellation for one; both return +true only while their target is running. Inline `agents` remain scoped to workflows started by that tool call. The API can await one job or a group and supports one-turn workers whose validated tool @@ -210,7 +210,7 @@ local w = subagents.workflows["workflow-1"] return w.status .. (w.result and ("\n" .. w.result) or "") ``` -Workflow fields are `id`, `status`, `result`, `error`, and `agents`. Agents are available by name or iteration and expose `name`, `status`, `output`, `error`, and `id`. Records are read-only. Workflow completion wakes you with its id; use another `subagents.lua` call to retrieve whichever outputs you need. +Workflow fields are `id`, `status`, `result`, `error`, and `agents`; `workflow:cancel()` requests cancellation of every running agent. Agents are available by name or iteration and expose `name`, `status`, `output`, `error`, and `id`; `agent:cancel()` requests cancellation of that agent. Fields are read-only, and cancellation returns true only while the target is running. Workflow completion wakes you with its id; use another `subagents.lua` call to retrieve whichever outputs you need. ]=] local function install_guidance(ext) @@ -349,11 +349,11 @@ local function activate() ext.register_tool { name = "subagents.lua", - description = "Run sandboxed Lua that starts background subagent workflows or inspects existing ones. `source` can call subagents.workflow(function(ctx) ... end), which returns a workflow id immediately, and can read immutable records from subagents.workflows. Optional `agents` define profiles available only to workflows started by this call.", + description = "Run sandboxed Lua that starts background subagent workflows or inspects existing ones. `source` can call subagents.workflow(function(ctx) ... end), which returns a workflow id immediately, inspect field-read-only records from subagents.workflows, and request cancellation through their cancel methods. Optional `agents` define profiles available only to workflows started by this call.", schema = { type = "object", properties = { - source = { type = "string", description = "Lua source that starts a workflow or inspects subagents.workflows." }, + source = { type = "string", description = "Lua source that starts a workflow, inspects subagents.workflows, or cancels its workflows and agents." }, agents = { type = "array", description = "Agent profiles available only to this workflow. Inline profiles shadow discovered profiles with the same name.", diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua index 62f9283..509b804 100644 --- a/spec/test_luatool.lua +++ b/spec/test_luatool.lua @@ -165,6 +165,46 @@ return { end) end }, + { "public workflow records cancel agents or every running agent", function() + with_host(function(handle, profiles) + handle.ext.workflows = workflow.workflows + handle.queue_for("alpha", { settle = 100000 }) + handle.queue_for("beta", { settle = 100000 }) + local id = luatool.handle({ source = [[ + return subagents.workflow(function(ctx) + local alpha = ctx:agent{name="alpha", agent="alpha", prompt="alpha"} + local beta = ctx:agent{name="beta", agent="beta", prompt="beta"} + ctx:await({alpha, beta}, "all") + return "completed" + end) + ]] }, profiles) + + local record = handle.ext.workflows[id] + for _ = 1, 100 do + uv.run("nowait") + if record.agents.alpha and record.agents.beta then break end + uv.sleep(1) + end + assert(type(record.cancel) == "function", "workflow exposes cancel") + assert(type(record.agents.alpha.cancel) == "function", "agent exposes cancel") + assert(record.agents.alpha:cancel(), "an in-flight agent accepts cancellation") + + for _ = 1, 100 do + uv.run("nowait") + if record.agents.alpha.status == "cancelled" then break end + uv.sleep(1) + end + assert(record.agents.alpha.status == "cancelled") + assert(record.agents.beta.status == "running") + assert(record:cancel(), "an in-flight workflow accepts cancellation") + + record = pump(id) + assert(record.status == "cancelled", tostring(record.error)) + assert(record.agents.beta.status == "cancelled") + assert(record:cancel() == false, "terminal workflows ignore cancellation") + end) + end }, + { "agent names are required and unique within a workflow", function() with_host(function(handle, profiles) local id = luatool.handle({ source = [[ diff --git a/subagents/jobs.lua b/subagents/jobs.lua index 9fe5c93..dc166a7 100644 --- a/subagents/jobs.lua +++ b/subagents/jobs.lua @@ -280,6 +280,21 @@ function handle_mt:cancel() end end +-- Cancel one known handle and wake parked awaiters. `handle:cancel()` remains +-- the narrow primitive; workflow records use this helper because cancelling a +-- queued child settles it synchronously. +function M.cancel(handle) + if getmetatable(handle) ~= handle_mt then + error("jobs.cancel expects a job handle", 2) + end + if handle.settled ~= nil then + return false + end + handle:cancel() + wake() + return true +end + -- start(startspec) -> handle | nil, err -- -- startspec = { diff --git a/subagents/workflow.lua b/subagents/workflow.lua index 375285f..97dd858 100644 --- a/subagents/workflow.lua +++ b/subagents/workflow.lua @@ -58,8 +58,8 @@ local ok_uv, uv = pcall(require, "luv") local M = {} --- Session-scoped background workflows. The model sees only immutable proxies; --- mutable state and live handles stay private in this module. +-- Session-scoped background workflows. The model sees field-read-only proxies +-- with cancellation closures; mutable state and live handles stay private. local workflow_sequence = 0 local workflow_records = {} local active_workflows = {} @@ -389,6 +389,13 @@ ctx_mt.__name = "subagents.ctx" -- exposing only `agent` and `await`. Weak keys so a finished run is collectable. local state = setmetatable({}, { __mode = "k" }) +local function cancel_agent_record(record) + if record.status ~= "running" or record.job == nil then + return false + end + return jobs().cancel(record.job) +end + local function make_agent_record(workflow_record, name) local record = { name = name, @@ -400,6 +407,8 @@ local function make_agent_record(workflow_record, name) record.proxy = readonly(function(_, key) if key == "name" or key == "status" or key == "output" or key == "error" or key == "id" then return record[key] + elseif key == "cancel" then + return function() return cancel_agent_record(record) end end end) workflow_record.agent_order[#workflow_record.agent_order + 1] = record @@ -412,6 +421,7 @@ local function settle_agent_record(record, result) record.status = result.status or "failed" record.id = result.id record.error = result.error + record.job = nil if result.output ~= nil then record.output = M.output_text(result) end @@ -493,6 +503,7 @@ function ctx_mt:agent(input) }) else job_of[handle] = job + if agent_record then agent_record.job = job end end s.job_count = s.job_count + 1 @@ -689,6 +700,17 @@ function M.output_text(result) return tostring(output) end +local function cancel_workflow_record(record) + if record.status ~= "running" then + return false + end + record.cancel_requested = true + for _, agent in ipairs(record.agent_order) do + cancel_agent_record(agent) + end + return true +end + local function make_workflow_record(id) local record = { id = id, @@ -719,6 +741,8 @@ local function make_workflow_record(id) return record[key] elseif key == "agents" then return record.agents_proxy + elseif key == "cancel" then + return function() return cancel_workflow_record(record) end end end) return record @@ -804,8 +828,8 @@ end function M.cancel_all(suppress_notification) for _, record in pairs(active_workflows) do - record.cancel_requested = true if suppress_notification then record.suppress_notification = true end + cancel_workflow_record(record) end end |
