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.
@@ -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.1.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.1.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"
@@ -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.1.0"
9
+ __version__ = "0.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) -> 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
- echo, kind = answer or note or "", "text"
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
- msg = {**msg, "content": LOOK_GONE}
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), messages=[system, *history],
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