taskwarden 0.1.0__tar.gz → 0.2.0__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.
- taskwarden-0.2.0/CHANGELOG.md +23 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/PKG-INFO +3 -2
- {taskwarden-0.1.0 → taskwarden-0.2.0}/README.md +2 -1
- {taskwarden-0.1.0 → taskwarden-0.2.0}/pyproject.toml +1 -1
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/__init__.py +1 -1
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/decisions.py +5 -2
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/host.py +23 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/loop.py +123 -5
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/sweeper.py +1 -1
- {taskwarden-0.1.0 → taskwarden-0.2.0}/tests/test_taskwarden.py +150 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/.github/workflows/test.yml +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/.gitignore +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/LICENSE +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/examples/shop.py +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/folders.py +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/gate.py +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/models.py +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/replies.py +0 -0
- {taskwarden-0.1.0 → taskwarden-0.2.0}/taskwarden/tools.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
New optional host hooks; a host without them behaves as in 0.1.0.
|
|
6
|
+
|
|
7
|
+
- `clock(now)`: the time rides on the first message of each request instead of the system
|
|
8
|
+
prompt, so the prompt stays in the model's cache across runs. `BaseHost` gives a UTC line.
|
|
9
|
+
- `stop_asked(task)`: the owner's Stop. Checked before each model call, each tool and before
|
|
10
|
+
parking; the run ends as `stopped`, answers every call it never reached, and posts what it
|
|
11
|
+
had already done (`STOPPED_REPLY`, `STOPPED_DID`).
|
|
12
|
+
- `take_messages(task)`: what the owner writes while a task runs joins it, at the next round
|
|
13
|
+
and again before the run finishes, so one reply covers both.
|
|
14
|
+
- `thought(org, thread, agent, text)`: the model's reasoning on each turn, for a live "why".
|
|
15
|
+
- `decisions.apply(..., shown=)`: how the chat shows an answer when it differs from what the
|
|
16
|
+
agent reads.
|
|
17
|
+
- Pictures in a user message (an owner's photo) are shown once and their words kept; a model
|
|
18
|
+
that can't see pictures is sent the words only.
|
|
19
|
+
- `Usage`: each run logs its model calls and tokens, cached ones included (DeepSeek, OpenAI).
|
|
20
|
+
|
|
21
|
+
## 0.1.0 (2026-11-04)
|
|
22
|
+
|
|
23
|
+
First release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskwarden
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -142,7 +142,8 @@ await sweeper.sweep(host, Session, redis, now=now, run=run) # on a timer: resume
|
|
|
142
142
|
`BaseHost` leaves out what only your app can know. Override it as you grow:
|
|
143
143
|
`sent_key` / `reach_key` (turn on the duplicate-send guard), `notify_parked` (push, SMS or
|
|
144
144
|
email the owner), `refuse_run` (credits), `context` (the business's time zone), `audit`
|
|
145
|
-
(store the trail), `mark` (live progress)
|
|
145
|
+
(store the trail), `mark` (live progress), `thought` (the model's reasoning, as it works), `take_messages` (what the owner writes while a task runs joins it), `stop_asked` (the owner's Stop: the run ends at its next step and says what it did), `clock` (how the time reads; it rides on the
|
|
146
|
+
first message of each request, never in the system prompt, so the prompt stays cached).
|
|
146
147
|
|
|
147
148
|
## Risk levels
|
|
148
149
|
|
|
@@ -118,7 +118,8 @@ await sweeper.sweep(host, Session, redis, now=now, run=run) # on a timer: resume
|
|
|
118
118
|
`BaseHost` leaves out what only your app can know. Override it as you grow:
|
|
119
119
|
`sent_key` / `reach_key` (turn on the duplicate-send guard), `notify_parked` (push, SMS or
|
|
120
120
|
email the owner), `refuse_run` (credits), `context` (the business's time zone), `audit`
|
|
121
|
-
(store the trail), `mark` (live progress)
|
|
121
|
+
(store the trail), `mark` (live progress), `thought` (the model's reasoning, as it works), `take_messages` (what the owner writes while a task runs joins it), `stop_asked` (the owner's Stop: the run ends at its next step and says what it did), `clock` (how the time reads; it rides on the
|
|
122
|
+
first message of each request, never in the system prompt, so the prompt stays cached).
|
|
122
123
|
|
|
123
124
|
## Risk levels
|
|
124
125
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "taskwarden"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -81,7 +81,8 @@ async def claim(host, db, organization_id: str, task_id: Any, decision: str):
|
|
|
81
81
|
async def apply(host, db, organization_id: str, task: Any, decision: str, *,
|
|
82
82
|
note: Optional[str] = None, answer: Optional[str] = None, resume: bool = False,
|
|
83
83
|
declined_calls: Optional[list[str]] = None,
|
|
84
|
-
edited_args: Optional[dict[str, dict]] = None
|
|
84
|
+
edited_args: Optional[dict[str, dict]] = None,
|
|
85
|
+
shown: Optional[str] = None) -> None:
|
|
85
86
|
"""Carry out a claimed decision: run what was approved (or record the decline, the change
|
|
86
87
|
asked for, or the answer), hand the results to the task and post the decision to its
|
|
87
88
|
thread. `task` is the claimed task, or its id from another session. `declined_calls`: on an
|
|
@@ -149,7 +150,9 @@ async def apply(host, db, organization_id: str, task: Any, decision: str, *,
|
|
|
149
150
|
if not is_approval:
|
|
150
151
|
results = [{"role": "tool", "tool_call_id": pending.get("tool_call_id", ""),
|
|
151
152
|
"content": f"Owner answered: {answer or note or ''}"}]
|
|
152
|
-
|
|
153
|
+
# `shown`: how the chat shows the owner's answer when it differs from what the agent
|
|
154
|
+
# reads (a file's name, not the whole file).
|
|
155
|
+
echo, kind = shown or answer or note or "", "text"
|
|
153
156
|
if is_approval and decision != "revise":
|
|
154
157
|
# What was decided, as data: the app words it for the owner, and the team's history
|
|
155
158
|
# reads it back as one line.
|
|
@@ -54,6 +54,18 @@ class Host(Protocol):
|
|
|
54
54
|
now: datetime,
|
|
55
55
|
channel: Optional[str], business_type: Optional[str],
|
|
56
56
|
self_started: bool, loaded: set[str]) -> str: ...
|
|
57
|
+
async def thought(self, organization_id: str, thread: str, agent_id: str, text: str) -> None:
|
|
58
|
+
"""The model's reasoning on its latest turn, for a live "why" beside the progress
|
|
59
|
+
line. Optional; called only when the model thinks."""
|
|
60
|
+
async def take_messages(self, task: Any) -> list[str]:
|
|
61
|
+
"""What the owner wrote to this task while it ran, removed as it is taken: the run
|
|
62
|
+
reads it at the start of each round and before it finishes. Optional."""
|
|
63
|
+
async def stop_asked(self, task: Any) -> bool:
|
|
64
|
+
"""The owner pressed Stop on this task: checked before each model call and each tool,
|
|
65
|
+
so the run ends cleanly at the next step (loop.stop). Optional."""
|
|
66
|
+
def clock(self, now: datetime) -> Optional[str]:
|
|
67
|
+
"""The time, in words, for the first message of each request. Kept out of the system
|
|
68
|
+
prompt: a prefix that changes every minute is never served from the model's cache."""
|
|
57
69
|
def local_schemas(self, channel: Optional[str], task: Any) -> list[dict]:
|
|
58
70
|
"""Tools the host answers inside the loop itself (a `show` that draws a table, say)."""
|
|
59
71
|
async def local_call(self, db: Any, organization_id: str, name: str, args: dict,
|
|
@@ -125,6 +137,8 @@ class BaseHost:
|
|
|
125
137
|
NEVER_WAITS_NEEDS_OWNER = ("Not done: this needs the owner, and this task can't wait for "
|
|
126
138
|
"them. Put it in your report.")
|
|
127
139
|
OUT_OF_STEPS = "I ran out of steps before finishing. Ask me to carry on."
|
|
140
|
+
STOPPED_REPLY = "Stopped."
|
|
141
|
+
STOPPED_DID = "Done before that: {did}."
|
|
128
142
|
|
|
129
143
|
# ── Write these ──
|
|
130
144
|
async def llm(self, db: Any, organization_id: str) -> tuple[Any, str]:
|
|
@@ -134,6 +148,15 @@ class BaseHost:
|
|
|
134
148
|
business_type, self_started, loaded) -> str:
|
|
135
149
|
raise NotImplementedError("return the words the agent reads first")
|
|
136
150
|
|
|
151
|
+
async def take_messages(self, task) -> list[str]:
|
|
152
|
+
return [] # write it to let the owner add to a request while it runs
|
|
153
|
+
|
|
154
|
+
async def stop_asked(self, task) -> bool:
|
|
155
|
+
return False # write it (a flag in Redis, say) to give the owner a Stop button
|
|
156
|
+
|
|
157
|
+
def clock(self, now) -> Optional[str]:
|
|
158
|
+
return f"Right now it is {now:%A %Y-%m-%d %H:%M} ({now.tzname()})."
|
|
159
|
+
|
|
137
160
|
# ── The roster ──
|
|
138
161
|
def agent(self, agent_id: str) -> Any:
|
|
139
162
|
return self.agents[agent_id]
|
|
@@ -77,7 +77,10 @@ PRACTICE = ("Practice mode: not done, nothing was sent or changed.\n{call}\nCarr
|
|
|
77
77
|
"request, and list in your report each action you would have taken.")
|
|
78
78
|
MAX_DELEGATIONS_PER_TASK = 5 # across every run of the task, approval resumes and retries included
|
|
79
79
|
DAILY_TASK_CAP = 200 # ponytail: flat per-business cap over a rolling 24h; plan-based when outgrown
|
|
80
|
-
DONE, WAITING = "done", "waiting"
|
|
80
|
+
DONE, WAITING, STOPPED = "done", "waiting", "stopped"
|
|
81
|
+
STOPPED_BEFORE = "Not run: the owner stopped the task first."
|
|
82
|
+
# A message the owner sent while the task was running (host.take_messages).
|
|
83
|
+
ADDED = "The owner adds, while you work: {text}"
|
|
81
84
|
THINKING = "thinking"
|
|
82
85
|
|
|
83
86
|
DELEGATE_SCHEMA = {"type": "function", "function": {
|
|
@@ -248,6 +251,17 @@ def assistant_turn(msg: Any) -> dict:
|
|
|
248
251
|
return turn
|
|
249
252
|
|
|
250
253
|
|
|
254
|
+
def reasoning_of(msg: Any) -> str:
|
|
255
|
+
"""The model's thinking on this turn, wherever the provider put it ("" when it had none)."""
|
|
256
|
+
turn = msg.model_dump(exclude_none=True) if hasattr(msg, "model_dump") else {}
|
|
257
|
+
found = (turn.get("reasoning_content")
|
|
258
|
+
or ((turn.get("extra_content") or {}).get("deepseek") or {}).get("reasoning_content")
|
|
259
|
+
or turn.get("reasoning"))
|
|
260
|
+
if isinstance(found, dict):
|
|
261
|
+
found = found.get("content")
|
|
262
|
+
return found.strip() if isinstance(found, str) else ""
|
|
263
|
+
|
|
264
|
+
|
|
251
265
|
def tool_msg(call_id: str, content: str) -> dict:
|
|
252
266
|
return {"role": "tool", "tool_call_id": call_id, "content": content}
|
|
253
267
|
|
|
@@ -352,6 +366,38 @@ async def answer_ask(host, db, child: Any, text: str) -> Optional[Any]:
|
|
|
352
366
|
return parent
|
|
353
367
|
|
|
354
368
|
|
|
369
|
+
async def asked_to_stop(host, task: Any) -> bool:
|
|
370
|
+
"""The owner pressed Stop (host.stop_asked, optional: without it nothing stops a run)."""
|
|
371
|
+
check = getattr(host, "stop_asked", None)
|
|
372
|
+
return bool(check is not None and await check(task))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def take_added(host, task: Any) -> list[dict]:
|
|
376
|
+
"""What the owner wrote since the last look, as user turns (host.take_messages, optional)."""
|
|
377
|
+
take = getattr(host, "take_messages", None)
|
|
378
|
+
texts = await take(task) if take is not None else []
|
|
379
|
+
return [{"role": "user", "content": ADDED.format(text=t)} for t in texts]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def stop(host, db, task: Any, history: list, did: list[str]) -> str:
|
|
383
|
+
"""End the run where it is, at the owner's request. What already ran stays done, every
|
|
384
|
+
call it had not reached is answered as not run, and the chat says what was done."""
|
|
385
|
+
state = [*history]
|
|
386
|
+
calls = next((m.get("tool_calls") for m in reversed(state) if isinstance(m, dict)
|
|
387
|
+
and m.get("role") == "assistant"), None) or []
|
|
388
|
+
answered = {m.get("tool_call_id") for m in state if isinstance(m, dict) and m.get("role") == "tool"}
|
|
389
|
+
state += [tool_msg(c["id"], STOPPED_BEFORE) for c in calls if c.get("id") not in answered]
|
|
390
|
+
task.state, task.pending, task.status = answer_dangling(state), None, STOPPED
|
|
391
|
+
reply = getattr(host, "STOPPED_REPLY", "Stopped.")
|
|
392
|
+
if did:
|
|
393
|
+
reply += " " + getattr(host, "STOPPED_DID", "Done before that: {did}.").format(did="; ".join(did))
|
|
394
|
+
host.post_message(db, organization_id=task.organization_id, thread=task.thread,
|
|
395
|
+
sender=task.agent_id, body=reply, task_id=task.id)
|
|
396
|
+
await answer_ask(host, db, task, reply) # a teammate waiting on this one carries on
|
|
397
|
+
await db.flush()
|
|
398
|
+
return STOPPED
|
|
399
|
+
|
|
400
|
+
|
|
355
401
|
async def _finish(host, db, task: Any, history: list, text: str, actor: str,
|
|
356
402
|
shown: list) -> str:
|
|
357
403
|
history.append({"role": "assistant", "content": text})
|
|
@@ -433,6 +479,17 @@ LOOK_GONE = "(pictures shown to you earlier)"
|
|
|
433
479
|
SEES = ("deepseek", "gpt", "claude", "gemini")
|
|
434
480
|
|
|
435
481
|
|
|
482
|
+
def with_clock(history: list, note: Optional[str]) -> list:
|
|
483
|
+
"""The request's copy of the history with the time at the top of its first message. Not
|
|
484
|
+
stored, and fixed for the run, so each round's request starts like the last one did."""
|
|
485
|
+
if not note:
|
|
486
|
+
return history
|
|
487
|
+
first = history[0] if history else None
|
|
488
|
+
if isinstance(first, dict) and first.get("role") == "user" and isinstance(first.get("content"), str):
|
|
489
|
+
return [{**first, "content": f"{note}\n\n{first['content']}"}, *history[1:]]
|
|
490
|
+
return [{"role": "user", "content": note}, *history]
|
|
491
|
+
|
|
492
|
+
|
|
436
493
|
def sees_pictures(model: str) -> bool:
|
|
437
494
|
return any(family in (model or "").lower() for family in SEES)
|
|
438
495
|
|
|
@@ -446,7 +503,10 @@ def seen(history: list) -> list:
|
|
|
446
503
|
content = msg.get("content") if isinstance(msg, dict) else None
|
|
447
504
|
if isinstance(content, list) and any(p.get("type") == "image_url" for p in content
|
|
448
505
|
if isinstance(p, dict)):
|
|
449
|
-
|
|
506
|
+
# Words that came with the pictures (the owner's message) stay.
|
|
507
|
+
words = [p.get("text", "") for p in content if isinstance(p, dict)
|
|
508
|
+
and p.get("type") == "text" and p.get("text") != LOOK_TEXT]
|
|
509
|
+
msg = {**msg, "content": "\n\n".join([*words, LOOK_GONE])}
|
|
450
510
|
out.append(msg)
|
|
451
511
|
return out
|
|
452
512
|
|
|
@@ -466,11 +526,45 @@ async def looks(host, images: list, model: str) -> Optional[dict]:
|
|
|
466
526
|
return {"role": "user", "content": parts} if len(parts) > 1 else None
|
|
467
527
|
|
|
468
528
|
|
|
529
|
+
class Usage:
|
|
530
|
+
"""A run's model calls and tokens; `cached` is the part of the input the provider had seen
|
|
531
|
+
(DeepSeek's prompt cache hits, OpenAI's cached tokens), billed at a fraction."""
|
|
532
|
+
|
|
533
|
+
def __init__(self) -> None:
|
|
534
|
+
self.requests = self.prompt = self.cached = self.completion = 0
|
|
535
|
+
|
|
536
|
+
def add(self, resp: Any) -> None:
|
|
537
|
+
self.requests += 1
|
|
538
|
+
u = getattr(resp, "usage", None)
|
|
539
|
+
if u is None:
|
|
540
|
+
return
|
|
541
|
+
self.prompt += getattr(u, "prompt_tokens", 0) or 0
|
|
542
|
+
self.completion += getattr(u, "completion_tokens", 0) or 0
|
|
543
|
+
details = getattr(u, "prompt_tokens_details", None)
|
|
544
|
+
self.cached += (getattr(u, "prompt_cache_hit_tokens", 0)
|
|
545
|
+
or getattr(details, "cached_tokens", 0) or 0)
|
|
546
|
+
|
|
547
|
+
|
|
469
548
|
async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
470
549
|
llm: Optional[tuple[Any, str]] = None, now: Optional[datetime] = None,
|
|
471
550
|
channel: Optional[str] = None, budget_seconds: Optional[float] = None) -> str:
|
|
472
551
|
"""One run of a task. With `budget_seconds`, a run still working when the time is up
|
|
473
|
-
checkpoints (status "queued") instead of being cut off mid-tool by its caller's timeout.
|
|
552
|
+
checkpoints (status "queued") instead of being cut off mid-tool by its caller's timeout.
|
|
553
|
+
Logs the run's tokens (`taskwarden_run_usage`) however it ends."""
|
|
554
|
+
usage = Usage()
|
|
555
|
+
try:
|
|
556
|
+
return await _run(host, db, task, usage, actor=actor, llm=llm, now=now, channel=channel,
|
|
557
|
+
budget_seconds=budget_seconds)
|
|
558
|
+
finally:
|
|
559
|
+
if usage.requests:
|
|
560
|
+
logger.info("taskwarden_run_usage task_id=%s agent=%s requests=%s prompt=%s "
|
|
561
|
+
"cached=%s completion=%s", task.id, task.agent_id, usage.requests,
|
|
562
|
+
usage.prompt, usage.cached, usage.completion)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
async def _run(host, db, task: Any, usage: Usage, *, actor: str, llm: Optional[tuple[Any, str]],
|
|
566
|
+
now: Optional[datetime], channel: Optional[str],
|
|
567
|
+
budget_seconds: Optional[float]) -> str:
|
|
474
568
|
started = time.monotonic()
|
|
475
569
|
now = now or datetime.now(timezone.utc)
|
|
476
570
|
org = task.organization_id
|
|
@@ -515,6 +609,8 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
515
609
|
local_now = now.astimezone(ZoneInfo(ctx.timezone))
|
|
516
610
|
system = {"role": "system", "content": await host.system_prompt(
|
|
517
611
|
db, task, agent, name, teammates, local_now, channel, business_type, self_started, loaded)}
|
|
612
|
+
clock = getattr(host, "clock", None)
|
|
613
|
+
note = clock(local_now) if clock else None
|
|
518
614
|
Task = host.Task
|
|
519
615
|
# Work this task already handed to someone counts, so a loop of hand-offs cannot outrun
|
|
520
616
|
# the caps across approval resumes and retries.
|
|
@@ -534,7 +630,11 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
534
630
|
# show from it: posted under its answer, dropped if the run parks instead.
|
|
535
631
|
reads: dict[str, Any] = {}
|
|
536
632
|
shown: list[dict] = []
|
|
633
|
+
did: list[str] = [] # what this run changed or sent, for the owner if they stop it
|
|
537
634
|
for round_no in range(MAX_ROUNDS):
|
|
635
|
+
if await asked_to_stop(host, task):
|
|
636
|
+
return await stop(host, db, task, history, did)
|
|
637
|
+
history += await take_added(host, task)
|
|
538
638
|
if round_no and too_long(history):
|
|
539
639
|
history = compact(history)
|
|
540
640
|
if round_no and stalled(history, 2) and not any(
|
|
@@ -551,10 +651,14 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
551
651
|
return "queued"
|
|
552
652
|
await host.mark(org, task.thread, agent.id, THINKING)
|
|
553
653
|
resp = await client.chat.completions.create(
|
|
554
|
-
model=model, extra_body=host.extra_body(model),
|
|
654
|
+
model=model, extra_body=host.extra_body(model),
|
|
655
|
+
messages=[system, *with_clock(history if sees_pictures(model) else seen(history), note)],
|
|
555
656
|
tools=tools, tool_choice="auto")
|
|
657
|
+
usage.add(resp)
|
|
556
658
|
history = seen(history) # the pictures were seen; they are not kept
|
|
557
659
|
msg = resp.choices[0].message
|
|
660
|
+
if (think := getattr(host, "thought", None)) is not None and (text := reasoning_of(msg)):
|
|
661
|
+
await think(org, task.thread, agent.id, text) # the owner can see why, as it works
|
|
558
662
|
if not msg.tool_calls:
|
|
559
663
|
text = host.clean_text(msg.content)
|
|
560
664
|
if not grounded and not nudged and states_a_figure(text):
|
|
@@ -569,6 +673,11 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
569
673
|
{"role": "user", "content": UNFINISHED}]
|
|
570
674
|
task.state = list(history)
|
|
571
675
|
continue
|
|
676
|
+
if added := await take_added(host, task):
|
|
677
|
+
# The owner wrote while this was being written: answer that too, in one reply.
|
|
678
|
+
history += [{"role": "assistant", "content": text}, *added]
|
|
679
|
+
task.state = list(history)
|
|
680
|
+
continue
|
|
572
681
|
return await _finish(host, db, task, history, text, actor, shown)
|
|
573
682
|
history.append(assistant_turn(msg))
|
|
574
683
|
parked: Optional[dict] = None
|
|
@@ -710,6 +819,8 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
710
819
|
"args": host.clean_args(args)})
|
|
711
820
|
continue
|
|
712
821
|
if decision.outcome == gate.ALLOW:
|
|
822
|
+
if await asked_to_stop(host, task):
|
|
823
|
+
return await stop(host, db, task, history, did)
|
|
713
824
|
await host.mark(org, task.thread, agent.id, tool_name)
|
|
714
825
|
if sent is not None:
|
|
715
826
|
# Saved before it runs: a crash mid-send must not lead to a second send.
|
|
@@ -727,6 +838,8 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
727
838
|
if ok:
|
|
728
839
|
grounded = True
|
|
729
840
|
if ok is not False: # None: stopped at its timeout, it may have gone out
|
|
841
|
+
if tool.risk != READ:
|
|
842
|
+
did.append(f"{tool_name} ({target})" if target else tool_name)
|
|
730
843
|
if reach is not None:
|
|
731
844
|
# Only a send that (maybe) went out counts: a rejected one leaves
|
|
732
845
|
# the retry open, which is the whole point of retrying.
|
|
@@ -746,6 +859,8 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
746
859
|
task.state, task.contacted = list(history), sorted(sent_task)
|
|
747
860
|
if parked is None and not awaiting:
|
|
748
861
|
await db.commit() # the round's progress survives a restart
|
|
862
|
+
if (parked is not None or awaiting) and await asked_to_stop(host, task):
|
|
863
|
+
return await stop(host, db, task, history, did)
|
|
749
864
|
if parked is not None:
|
|
750
865
|
# A question wins over a batch in the same response: the answer may change what to
|
|
751
866
|
# send. Each held call still needs its tool message; the model proposes it again.
|
|
@@ -764,9 +879,12 @@ async def run_task(host, db, task: Any, *, actor: str = "task",
|
|
|
764
879
|
task.state = list(history)
|
|
765
880
|
return await park(host, db, task, history, batch, name, actor, channel, now)
|
|
766
881
|
|
|
882
|
+
if await asked_to_stop(host, task):
|
|
883
|
+
return await stop(host, db, task, history, did)
|
|
767
884
|
wrap_up = {"role": "user", "content": WRAP_UP}
|
|
768
885
|
resp = await client.chat.completions.create(model=model, extra_body=host.extra_body(model),
|
|
769
|
-
messages=[system, *history, wrap_up])
|
|
886
|
+
messages=[system, *with_clock(history, note), wrap_up])
|
|
887
|
+
usage.add(resp)
|
|
770
888
|
text = resp.choices[0].message.content or ""
|
|
771
889
|
return await _finish(host, db, task, history, host.clean_text(text) or host.OUT_OF_STEPS,
|
|
772
890
|
actor, shown)
|
|
@@ -33,7 +33,7 @@ OWNER_ORIGIN = "owner"
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
async def retry_or_fail(host, db, task: Any) -> None:
|
|
36
|
-
if task.status in ("waiting", "done"):
|
|
36
|
+
if task.status in ("waiting", "done", "stopped"):
|
|
37
37
|
return # the run parked or finished before failing; its committed state stands
|
|
38
38
|
if (task.attempts or 0) >= MAX_ATTEMPTS:
|
|
39
39
|
task.status, task.pending = "failed", None
|
|
@@ -13,6 +13,7 @@ import uuid
|
|
|
13
13
|
import pytest
|
|
14
14
|
from contextlib import nullcontext
|
|
15
15
|
from dataclasses import dataclass, field, replace
|
|
16
|
+
from datetime import datetime, timezone
|
|
16
17
|
from types import SimpleNamespace
|
|
17
18
|
from unittest.mock import AsyncMock, MagicMock
|
|
18
19
|
|
|
@@ -347,3 +348,152 @@ def test_the_shop_example_runs(capsys):
|
|
|
347
348
|
out = capsys.readouterr().out
|
|
348
349
|
assert "waits: refund {'order_id': 'A7'}" in out
|
|
349
350
|
assert "[lead] Done: I refunded order A8, $19.50." in out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ── A run's tokens are logged, with what the provider had cached ──
|
|
354
|
+
|
|
355
|
+
def test_usage_counts_deepseek_and_openai_cache_hits():
|
|
356
|
+
u = loop.Usage()
|
|
357
|
+
u.add(SimpleNamespace(usage=SimpleNamespace(prompt_tokens=1000, completion_tokens=50,
|
|
358
|
+
prompt_cache_hit_tokens=900))) # DeepSeek
|
|
359
|
+
u.add(SimpleNamespace(usage=SimpleNamespace(prompt_tokens=800, completion_tokens=20,
|
|
360
|
+
prompt_tokens_details=SimpleNamespace(cached_tokens=640)))) # OpenAI
|
|
361
|
+
u.add(SimpleNamespace()) # no usage reported
|
|
362
|
+
assert (u.requests, u.prompt, u.cached, u.completion) == (3, 1800, 1540, 70)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def test_a_run_logs_its_usage_however_it_ends(caplog):
|
|
366
|
+
host, task = ShopHost(), _task()
|
|
367
|
+
llm = _llm(Msg(calls=[("read_orders", {})]), Msg("Two orders came in today."))
|
|
368
|
+
with caplog.at_level("INFO", logger="taskwarden.loop"):
|
|
369
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "m")) == "done"
|
|
370
|
+
assert any("taskwarden_run_usage" in r.getMessage() and "requests=2" in r.getMessage()
|
|
371
|
+
for r in caplog.records)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class ClockHost(ShopHost):
|
|
375
|
+
def clock(self, now): return f"It is {now:%H:%M}."
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def test_the_clock_tops_every_request_and_is_never_stored_or_in_the_system_prompt():
|
|
379
|
+
host, task, sent = ClockHost(), _task(), []
|
|
380
|
+
replies = [Msg(calls=[("read_orders", {})]), Msg("Two orders.")]
|
|
381
|
+
|
|
382
|
+
async def create(**kw):
|
|
383
|
+
sent.append(kw["messages"])
|
|
384
|
+
return SimpleNamespace(choices=[SimpleNamespace(message=replies.pop(0))])
|
|
385
|
+
llm = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
|
|
386
|
+
now = datetime(2026, 11, 5, 9, 30, tzinfo=timezone.utc)
|
|
387
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "m"), now=now) == "done"
|
|
388
|
+
assert [m[1]["content"] for m in sent] == ["It is 09:30.\n\nHow are orders today?"] * 2
|
|
389
|
+
assert all("09:30" not in m[0]["content"] for m in sent)
|
|
390
|
+
assert task.state[0] == {"role": "user", "content": "How are orders today?"}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class StoppableHost(TextingHost):
|
|
395
|
+
stop_after: int = 0 # Stop is pressed once this many texts went out
|
|
396
|
+
texts: int = 0
|
|
397
|
+
|
|
398
|
+
async def stop_asked(self, task):
|
|
399
|
+
return self.texts >= self.stop_after
|
|
400
|
+
|
|
401
|
+
async def execute(self, tool, ctx, args):
|
|
402
|
+
self.texts += tool.name == "send_text"
|
|
403
|
+
return await super().execute(tool, ctx, args)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def test_a_stop_pressed_before_the_run_ends_it_before_the_model_is_asked():
|
|
407
|
+
host, task = StoppableHost(stop_after=0), _task()
|
|
408
|
+
assert await loop.run_task(host, _db(), task, llm=(_llm(), "m")) == "stopped"
|
|
409
|
+
assert task.status == "stopped" and host.posted[-1]["body"] == "Stopped."
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def test_a_stop_mid_round_keeps_what_was_sent_and_runs_nothing_more():
|
|
413
|
+
host, task = StoppableHost(stop_after=1), _task()
|
|
414
|
+
host.agent = lambda agent_id: Agent(agent_id, "Lead", tools=("read_orders", "send_text"))
|
|
415
|
+
llm = _llm(Msg(calls=[("send_text", {"to": "+1555", "body": "Hi"}),
|
|
416
|
+
("send_text", {"to": "+1666", "body": "Hi"})]))
|
|
417
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "m")) == "stopped"
|
|
418
|
+
assert host.texts == 1
|
|
419
|
+
assert host.posted[-1]["body"] == "Stopped. Done before that: send_text (+1555)."
|
|
420
|
+
tool_results = [m["content"] for m in task.state if m.get("role") == "tool"]
|
|
421
|
+
assert tool_results == ["Sent to +1555.", loop.STOPPED_BEFORE] # every call answered
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass
|
|
425
|
+
class ChattyHost(ShopHost):
|
|
426
|
+
inbox: list = field(default_factory=list) # (look number, text): what the owner sends when
|
|
427
|
+
looks: int = 0
|
|
428
|
+
|
|
429
|
+
async def take_messages(self, task):
|
|
430
|
+
self.looks += 1
|
|
431
|
+
return [text for at, text in self.inbox if at == self.looks]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _recording(*replies):
|
|
435
|
+
sent, queue = [], list(replies)
|
|
436
|
+
|
|
437
|
+
async def create(**kw):
|
|
438
|
+
sent.append(kw["messages"])
|
|
439
|
+
return SimpleNamespace(choices=[SimpleNamespace(message=queue.pop(0))])
|
|
440
|
+
return SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))), sent
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
async def test_a_message_sent_mid_run_reaches_the_next_round():
|
|
444
|
+
host, task = ChattyHost(inbox=[(2, "Only count paid ones.")]), _task()
|
|
445
|
+
llm, sent = _recording(Msg(calls=[("read_orders", {})]), Msg("One paid order."))
|
|
446
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "m")) == "done"
|
|
447
|
+
assert sent[1][-1] == {"role": "user", "content": "The owner adds, while you work: Only count paid ones."}
|
|
448
|
+
assert host.posted[-1] == {"body": "One paid order."}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def test_a_message_sent_as_the_reply_was_written_gets_one_reply_covering_both():
|
|
452
|
+
host, task = ChattyHost(inbox=[(2, "And yesterday?")]), _task()
|
|
453
|
+
llm, sent = _recording(Msg("Two orders today."), Msg("Two today, three yesterday."))
|
|
454
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "m")) == "done"
|
|
455
|
+
assert [m["content"] for m in sent[1][-2:]] == [
|
|
456
|
+
"Two orders today.", "The owner adds, while you work: And yesterday?"]
|
|
457
|
+
assert [p["body"] for p in host.posted] == ["Two today, three yesterday."]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class Thinking(Msg):
|
|
461
|
+
def __init__(self, reasoning, **kw):
|
|
462
|
+
super().__init__(**kw)
|
|
463
|
+
self.reasoning = reasoning
|
|
464
|
+
|
|
465
|
+
def model_dump(self, exclude_none=False):
|
|
466
|
+
return {**super().model_dump(exclude_none), "reasoning_content": self.reasoning}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@dataclass
|
|
470
|
+
class ThinkingHost(ShopHost):
|
|
471
|
+
thoughts: list = field(default_factory=list)
|
|
472
|
+
|
|
473
|
+
async def thought(self, org, thread, agent_id, text):
|
|
474
|
+
self.thoughts.append((agent_id, text))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def test_the_models_thinking_is_handed_to_the_host_as_it_works():
|
|
478
|
+
host, task = ThinkingHost(), _task()
|
|
479
|
+
llm = _llm(Thinking("Read the orders first.", calls=[("read_orders", {})]), Msg("Two orders."))
|
|
480
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "m")) == "done"
|
|
481
|
+
assert host.thoughts == [("lead", "Read the orders first.")] # a turn with none says nothing
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_a_picture_seen_once_leaves_the_words_it_came_with():
|
|
485
|
+
owner = {"role": "user", "content": [{"type": "text", "text": "Post this photo"},
|
|
486
|
+
{"type": "image_url", "image_url": {"url": "data:x"}}]}
|
|
487
|
+
assert loop.seen([owner]) == [{"role": "user", "content": f"Post this photo\n\n{loop.LOOK_GONE}"}]
|
|
488
|
+
look = {"role": "user", "content": [{"type": "text", "text": loop.LOOK_TEXT},
|
|
489
|
+
{"type": "image_url", "image_url": {"url": "data:x"}}]}
|
|
490
|
+
assert loop.seen([look]) == [{"role": "user", "content": loop.LOOK_GONE}]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def test_a_model_that_cant_see_is_never_sent_a_picture():
|
|
494
|
+
host, task = ShopHost(), _task()
|
|
495
|
+
task.state = [{"role": "user", "content": [{"type": "text", "text": "What is this?"},
|
|
496
|
+
{"type": "image_url", "image_url": {"url": "data:x"}}]}]
|
|
497
|
+
llm, sent = _recording(Msg("A receipt."))
|
|
498
|
+
assert await loop.run_task(host, _db(), task, llm=(llm, "llama-3")) == "done"
|
|
499
|
+
assert "image_url" not in json.dumps(sent[0])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|