summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md7
-rw-r--r--init.lua6
-rw-r--r--spec/test_luatool.lua40
-rw-r--r--subagents/jobs.lua15
-rw-r--r--subagents/workflow.lua30
5 files changed, 90 insertions, 8 deletions
diff --git a/README.md b/README.md
index 1cbf917..e985c18 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/init.lua b/init.lua
index 3175cfa..2fd89d5 100644
--- a/init.lua
+++ b/init.lua
@@ -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