taskwarden 0.2.0__tar.gz → 0.2.1__tar.gz

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.
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 (2026-11-04)
4
+
5
+ - An approved call is checked again before it runs, on what will really run: the gate (an
6
+ agent paused or a tool taken away while it waited gets `NOT_ALLOWED`, audited as
7
+ `no_longer_allowed`) and the tool's own `check`, on the owner's edits too (audited as
8
+ `precheck`). Before, only the tool's existence was checked at approval time.
9
+
3
10
  ## 0.2.0 (2026-11-04)
4
11
 
5
12
  New optional host hooks; a host without them behaves as in 0.1.0.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskwarden
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A durable, owner-approved AI team for a business: agents as folders, tools by risk, owner approvals that survive restarts, on Postgres.
5
5
  Project-URL: Repository, https://github.com/dahoai/taskwarden
6
6
  Author: DAHO
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskwarden"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "A durable, owner-approved AI team for a business: agents as folders, tools by risk, owner approvals that survive restarts, on Postgres."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -6,4 +6,4 @@ call), decisions (the owner's approve / edit / decline), sweeper (background run
6
6
  columns), host (what the app supplies). See README.md.
7
7
  """
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.2.1"
@@ -11,6 +11,7 @@ from typing import Any, Optional
11
11
 
12
12
  from sqlalchemy import select, update
13
13
 
14
+ from . import gate
14
15
  from .loop import MAYBE_SENT, pending_calls, tool_msg
15
16
 
16
17
  # What a parked call reads when the owner wrote in the chat instead of approving it.
@@ -19,6 +20,8 @@ REVISE_RESULT = ("Not done yet: the owner replied instead of approving: \"{chang
19
20
  "something else, do that, and ask for this action again only if it still makes sense.")
20
21
  # A parked call whose tool no longer exists when the owner decides.
21
22
  GONE = "Not run: this action no longer exists. Say what you meant to do another way."
23
+ # A parked call the agent may no longer make (paused, or the tool taken away while it waited).
24
+ NOT_ALLOWED = "Not run: the owner approved it, but you may no longer do this. Say so in your report."
22
25
  # What the model reads before the result of a call the owner edited, so its report names
23
26
  # what really went out.
24
27
  EDITED = "The owner changed {fields} before approving, and it ran as changed. "
@@ -112,12 +115,27 @@ async def apply(host, db, organization_id: str, task: Any, decision: str, *,
112
115
  ctx, company = await host.context(db, organization_id)
113
116
  region = host.region(company)
114
117
  sent = set(task.contacted or [])
118
+ # Checked again now, on what will really run: the call may have waited days, and the
119
+ # owner's edits never went through the check made when it was parked.
120
+ agent = host.for_business(host.agent(task.agent_id), company.get("business_type"))
121
+ state = (await host.agent_states(db, organization_id)).get(agent.id, host.default_state())
115
122
  for call, run, changed in zip(calls, runs, changes):
116
123
  tool = host.tools.get(call["tool"])
117
124
  if tool is None: # renamed or removed while it waited for the owner
118
125
  results.append(tool_msg(call.get("tool_call_id", ""), GONE))
119
126
  continue
120
127
  if run:
128
+ refusal, rule = None, None
129
+ if host.decide(agent, tool, state, self_started=False).outcome == gate.DENY:
130
+ refusal, rule = NOT_ALLOWED, "no_longer_allowed"
131
+ elif tool.check is not None:
132
+ refusal, rule = await host.precheck(tool, ctx, call.get("args") or {}), "precheck"
133
+ if refusal:
134
+ host.audit(db, organization_id=organization_id, agent_id=task.agent_id,
135
+ tool=call["tool"], risk=tool.risk, decision="deny", rule=rule,
136
+ task_id=task.id, actor="owner")
137
+ results.append(tool_msg(call.get("tool_call_id", ""), refusal))
138
+ continue
121
139
  key = host.sent_key(call["tool"], call.get("args") or {}, region)
122
140
  if key is not None:
123
141
  # Saved before it runs, as in the loop: a crash mid-send must not lead to a
@@ -297,6 +297,36 @@ async def test_an_edited_approval_runs_as_edited_and_the_agent_is_told():
297
297
  assert json.loads(host.posted[-1]["body"])["calls"][0]["args"] == {"order_id": "A8"}
298
298
 
299
299
 
300
+ async def _park_refund(host):
301
+ task, db = _task(), _db()
302
+ assert await loop.run_task(host, db, task, llm=(_llm(Msg(calls=[("refund", {"order_id": "A7"})])), "m")) == "waiting"
303
+ db.execute = AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: task))
304
+ host.execute = AsyncMock(side_effect=AssertionError("must not run"))
305
+ host.audit = MagicMock()
306
+ return task, db
307
+
308
+
309
+ async def test_an_approval_is_checked_again_on_the_args_the_owner_edited():
310
+ host = ShopHost()
311
+ host.tools["refund"] = replace(host.tools["refund"], check=AsyncMock())
312
+ host.precheck = AsyncMock(side_effect=lambda tool, ctx, args:
313
+ "Order A8 was already refunded." if args["order_id"] == "A8" else None)
314
+ task, db = await _park_refund(host)
315
+ await decisions.apply(host, db, "shop1", task, "approve", edited_args={"c0": {"order_id": "A8"}})
316
+ assert {"role": "tool", "tool_call_id": "c0", "content": "Order A8 was already refunded."} in task.state
317
+ assert host.audit.call_args.kwargs["rule"] == "precheck"
318
+
319
+
320
+ async def test_an_approval_for_an_agent_paused_while_it_waited_does_not_run():
321
+ host = ShopHost()
322
+ task, db = await _park_refund(host)
323
+ host.agent_states = AsyncMock(return_value={"lead": gate.AgentState(paused=True)})
324
+ await decisions.apply(host, db, "shop1", task, "approve")
325
+ assert {"role": "tool", "tool_call_id": "c0", "content": decisions.NOT_ALLOWED} in task.state
326
+ assert host.audit.call_args.kwargs["rule"] == "no_longer_allowed"
327
+ assert task.status == "queued" # the agent reads why and reports
328
+
329
+
300
330
  # ── A long run sheds its old tool results before it is made to wrap up ──
301
331
 
302
332
  def test_old_results_are_cut_to_their_opening_and_the_latest_stay_whole():
File without changes
File without changes
File without changes
File without changes