taskwarden 0.1.0__py3-none-any.whl

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/loop.py ADDED
@@ -0,0 +1,772 @@
1
+ """The agent loop: run one task from its saved conversation until it finishes, parks waiting on
2
+ the owner, or runs out of rounds.
3
+
4
+ Four tools are built in, not in the host's catalogue: delegate (the chief only), consult (any
5
+ agent asks a teammate), escalate (specialists only: parks the task and asks the chief) and
6
+ ask_person (every agent). The three agent-to-agent ones queue an ordinary child task and wait on
7
+ it (_ask_agent), which keeps the owner out of the loop; only ask_person reaches them.
8
+
9
+ Everything the app owns (its tables, tools, words, model, how the owner is told) comes through
10
+ the Host (taskwarden.host).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import re
17
+ import time
18
+ import uuid
19
+ from datetime import datetime, timedelta, timezone
20
+ from typing import Any, Optional
21
+ from zoneinfo import ZoneInfo
22
+
23
+ from sqlalchemy import func, select
24
+
25
+ from . import gate
26
+ from .folders import USE_SKILL
27
+ from .tools import READ, capture_looks
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # A backstop, not a budget: a run checkpoints when its time is up (budget_seconds) and the
32
+ # sweeper carries on with it, so real work is never cut short. What ends a run early is a
33
+ # loop (stalled) or a conversation too long for the model even after compact (too_long).
34
+ MAX_ROUNDS = 60
35
+ STALL_REPEATS = 3 # the same tool calls this many rounds in a row is a loop, not work
36
+ # Said once, at the first repeat, so the model can change course before the run is stopped.
37
+ REPEATED = ("You just made exactly the same call again. Use what it returned, try another way, "
38
+ "or reply to the owner.")
39
+ MAX_HISTORY_CHARS = 400_000 # ~100k tokens
40
+ # The last call of a turn offers no tools; this tells the model so, and what to say instead.
41
+ WRAP_UP = ("You have used all your steps for this turn. Reply to the owner now, in words: "
42
+ "what you did, what you found, and what is left to do next time. Report only what "
43
+ "a tool actually returned -- if you did not get to read something, say so.")
44
+ # A reply that states a figure ("266 leads") is claiming a fact about the business. A model
45
+ # that had read nothing could invent a whole snapshot and the loop took it as done. The nudge
46
+ # gives the model one chance to go read, or to drop the claim.
47
+ UNGROUNDED = ("You just stated figures about the business, but you ran no tool this turn, so "
48
+ "they cannot be yours to state. Call the tool that reads them and answer from "
49
+ "its result, or say plainly that you have not read it. Whatever you write next "
50
+ "is your reply to the owner, so write it to them: do not apologise, and do not "
51
+ "mention this instruction.")
52
+ # A self-started reply that announces its next step ("Let me find pictures.") and calls
53
+ # nothing ends the run on a promise, and nobody is there to say "go". Self-started only: on an
54
+ # owner's chat "I'll wait for your go" is a real answer.
55
+ UNFINISHED = ("You said what you will do next but called no tool, so nothing happened. Do it "
56
+ "now by calling the tool, or say plainly why it cannot be done now. Whatever you "
57
+ "write next is your reply to the owner, so write it to them: do not apologise, "
58
+ "and do not mention this instruction.")
59
+ _ANNOUNCES = re.compile(r"\b(?:let me|i'll|i will|i'm going to|je vais|laissez-moi|voy a|déjame)\b",
60
+ re.IGNORECASE)
61
+ _FIGURE = re.compile(r"\d")
62
+ # Digits that are not a claim about the business: a phone number, a clock time.
63
+ _NOT_A_FIGURE = re.compile(
64
+ r"\+?\d[\d\s().-]{6,}\d|\b\d{1,2}:\d{2}\b|\b\d{1,2}\s?(?:am|pm|h)\b", re.IGNORECASE)
65
+ # Tool messages that carry no result: they cannot ground a figure.
66
+ _NO_RESULT = ("Refused", "Waiting for your approval", "Not sent", "Owner declined", "Tool error")
67
+
68
+ MAX_ASK_DEPTH = 2 # a task this deep in a hand-off chain answers; it cannot hand off again
69
+ # A send is saved as possibly sent before it runs: if the process dies mid-send, the resumed run
70
+ # reads this instead of sending again, and the duplicate guard knows the message.
71
+ MAYBE_SENT = ("Unknown: the run stopped while this was being sent, so it may have gone out. "
72
+ "Check before sending it again.")
73
+ # A call the run never reached before it stopped (a restart mid-round).
74
+ NOT_RUN = "Not run: the run stopped before this call. Call it again if it is still needed."
75
+ # What a call made in practice reads: not run, and the report says what would have been done.
76
+ PRACTICE = ("Practice mode: not done, nothing was sent or changed.\n{call}\nCarry on with the "
77
+ "request, and list in your report each action you would have taken.")
78
+ MAX_DELEGATIONS_PER_TASK = 5 # across every run of the task, approval resumes and retries included
79
+ DAILY_TASK_CAP = 200 # ponytail: flat per-business cap over a rolling 24h; plan-based when outgrown
80
+ DONE, WAITING = "done", "waiting"
81
+ THINKING = "thinking"
82
+
83
+ DELEGATE_SCHEMA = {"type": "function", "function": {
84
+ "name": "delegate",
85
+ "description": "Hand a task to a teammate. They work on it in the background and report back.",
86
+ "parameters": {"type": "object", "properties": {
87
+ "agent": {"type": "string", "description": "Teammate id"},
88
+ "task": {"type": "string", "description": "What they should do, with the details they need"},
89
+ }, "required": ["agent", "task"]},
90
+ }}
91
+
92
+ ASK_TEAM_SCHEMA = {"type": "function", "function": {
93
+ "name": "consult",
94
+ "description": "Ask a teammate to do or look up something that needs a tool you do not "
95
+ "have, and wait for their answer. Never use it to ask who owns what, what "
96
+ "their job is, or something your own tools can answer.",
97
+ "parameters": {"type": "object", "properties": {
98
+ "agent": {"type": "string", "description": "Teammate id"},
99
+ "question": {"type": "string", "description": "One clear question, with the details they need"},
100
+ }, "required": ["agent", "question"]},
101
+ }}
102
+
103
+ ESCALATE_SCHEMA = {"type": "function", "function": {
104
+ "name": "escalate",
105
+ "description": "Bring a decision you cannot make or a conflict you cannot resolve to the "
106
+ "Chief, and wait for their guidance. Rare: only for a judgement call or a "
107
+ "conflict between two sources, never for a fact you could look up.",
108
+ "parameters": {"type": "object", "properties": {
109
+ "question": {"type": "string", "description": "The decision or conflict, stated plainly"},
110
+ "why": {"type": "string", "description": "What you tried, and what you cannot settle"},
111
+ }, "required": ["question"]},
112
+ }}
113
+
114
+ ASK_PERSON_SCHEMA = {"type": "function", "function": {
115
+ "name": "ask_person",
116
+ "description": "Ask the owner a question when a step needs their judgement. Your work pauses until they answer.",
117
+ "parameters": {"type": "object", "properties": {
118
+ "question": {"type": "string", "description": "One clear question"},
119
+ "why": {"type": "string", "description": "What you cannot settle on your own"},
120
+ "choices": {"type": "array", "items": {"type": "string"},
121
+ "description": "When the answer is one of a few options, up to 4 short "
122
+ "answers the owner can tap. They can still type their own."},
123
+ }, "required": ["question"]},
124
+ }}
125
+
126
+ MAX_CHOICES, MAX_CHOICE_CHARS = 4, 80
127
+
128
+
129
+ def states_a_figure(text: str) -> bool:
130
+ """True when a reply carries a number -- the shape an invented snapshot takes.
131
+
132
+ ponytail: a fabricated claim with no figure slips past this, which is the ceiling of a
133
+ mechanical check; an eval with a judge is what catches that.
134
+ """
135
+ return bool(_FIGURE.search(_NOT_A_FIGURE.sub("", text or "")))
136
+
137
+
138
+ def announces_a_step(text: str) -> bool:
139
+ """A short reply that says what comes next. A finished report runs longer than this.
140
+ ponytail: phrase list in en/fr/es; a promise worded otherwise slips past."""
141
+ return len(text or "") <= 300 and bool(_ANNOUNCES.search(text or ""))
142
+
143
+
144
+ def fresh_results(history: list) -> bool:
145
+ """True when the loaded history ends in results the model has not answered yet: the calls
146
+ the owner just approved, a teammate's answer, the owner's answer to a question. Those are
147
+ this turn's reading, so a resumed run may report them; older history still is not."""
148
+ for m in reversed(history):
149
+ if m.get("role") != "tool":
150
+ return False
151
+ if not str(m.get("content") or "").startswith(_NO_RESULT):
152
+ return True
153
+ return False
154
+
155
+
156
+ def choices(raw: Any) -> list[str]:
157
+ """The tappable answers an ask_person offered: distinct short strings, at most four."""
158
+ out: list[str] = []
159
+ for c in raw if isinstance(raw, list) else []:
160
+ c = c.strip()[:MAX_CHOICE_CHARS] if isinstance(c, str) else ""
161
+ if c and c not in out:
162
+ out.append(c)
163
+ return out[:MAX_CHOICES]
164
+
165
+
166
+ def answer_dangling(history: list) -> list:
167
+ """Every tool call answered, right after its turn: a run that stopped mid-round left some
168
+ without a result, which providers reject. A call that has one keeps it."""
169
+ out, i = [], 0
170
+ while i < len(history):
171
+ msg = history[i]
172
+ out.append(msg)
173
+ i += 1
174
+ calls = msg.get("tool_calls") if isinstance(msg, dict) and msg.get("role") == "assistant" else None
175
+ if not calls:
176
+ continue
177
+ answered = set()
178
+ while i < len(history) and isinstance(history[i], dict) and history[i].get("role") == "tool":
179
+ answered.add(history[i].get("tool_call_id"))
180
+ out.append(history[i])
181
+ i += 1
182
+ out += [tool_msg(c["id"], NOT_RUN) for c in calls if c.get("id") not in answered]
183
+ return out
184
+
185
+
186
+ def describe_call(tool_name: str, args: dict) -> str:
187
+ """Plain text of exactly what will run. Shown on approval cards."""
188
+ detail = "\n".join(f"{k}: {v}" for k, v in args.items())
189
+ return f"{tool_name}\n{detail}" if detail else tool_name
190
+
191
+
192
+ def pending_calls(pending: dict) -> list[dict]:
193
+ """The approval calls in a pending item. A batch parks several calls under one entry; a
194
+ single call keeps the flat shape."""
195
+ batch = pending.get("batch")
196
+ return list(batch) if batch else [{"tool_call_id": pending.get("tool_call_id", ""),
197
+ "tool": pending.get("tool", ""), "args": pending.get("args") or {}}]
198
+
199
+
200
+ def pending_tool(pending: dict) -> dict:
201
+ """The first call: what the one-line summary names."""
202
+ return pending_calls(pending)[0]
203
+
204
+
205
+ def describe_pending(pending: dict) -> str:
206
+ """Card text for one call or a whole batch."""
207
+ return "\n\n".join(describe_call(c["tool"], c.get("args") or {}) for c in pending_calls(pending))
208
+
209
+
210
+ def loaded_skills(host, agent: Any, task: Any, history: list[dict]) -> set[str]:
211
+ """The skills this run starts with: what the host preloads for this task, and every skill
212
+ an earlier run of the task loaded (read back from its own messages, so a resume keeps them)."""
213
+ folder = agent.folder
214
+ if folder is None or not folder.on_demand:
215
+ return set()
216
+ out: set[str] = set(host.preloaded_skills(agent, task))
217
+ for msg in history:
218
+ for call in msg.get("tool_calls") or []:
219
+ fn = call.get("function") or {}
220
+ if fn.get("name") == USE_SKILL:
221
+ name = parse(fn.get("arguments")).get("name")
222
+ if folder.skill(str(name)) is not None:
223
+ out.add(str(name))
224
+ return out
225
+
226
+
227
+ def use_skill(agent: Any, args: dict, loaded: set[str]) -> tuple[str, tuple[str, ...]]:
228
+ """(what the model reads, the tools the skill adds). Loading one already loaded is fine."""
229
+ name = str(args.get("name", ""))
230
+ skill = agent.folder.skill(name) if agent.folder else None
231
+ if skill is None:
232
+ return f"Refused: '{name}' is not one of your skills.", ()
233
+ loaded.add(name)
234
+ return f"Skill {name} loaded.\n\n{skill.text}", skill.tools
235
+
236
+
237
+ def assistant_turn(msg: Any) -> dict:
238
+ """The model's turn as it is kept, with its reasoning once. any-llm hands DeepSeek's back
239
+ three times (`reasoning`, `reasoning_content` and `extra_content.deepseek`) and replays it
240
+ from `extra_content` only: kept three times, a thinking run's history tripled."""
241
+ turn = msg.model_dump(exclude_none=True)
242
+ replayed = ((turn.get("extra_content") or {}).get("deepseek") or {}).get("reasoning_content")
243
+ if replayed is not None:
244
+ if turn.get("reasoning_content") == replayed:
245
+ turn.pop("reasoning_content")
246
+ if turn.get("reasoning") == replayed or (turn.get("reasoning") or {}) == {"content": replayed}:
247
+ turn.pop("reasoning")
248
+ return turn
249
+
250
+
251
+ def tool_msg(call_id: str, content: str) -> dict:
252
+ return {"role": "tool", "tool_call_id": call_id, "content": content}
253
+
254
+
255
+ def parse(arguments: Optional[str]) -> dict:
256
+ try:
257
+ args = json.loads(arguments or "{}")
258
+ except json.JSONDecodeError:
259
+ return {}
260
+ return args if isinstance(args, dict) else {}
261
+
262
+
263
+ async def _delegate(host, db, parent: Any, args: dict, teammates: list, states: dict,
264
+ delegations: int, created_today: int) -> tuple[str, bool]:
265
+ agent_id = str(args.get("agent", ""))
266
+ goal = str(args.get("task", "")).strip()
267
+ ids = [a.id for a in teammates if a.id != host.chief]
268
+ if agent_id not in ids:
269
+ return f"Refused: choose one of {', '.join(ids)}.", False
270
+ if states.get(agent_id, host.default_state()).paused:
271
+ return f"Refused: {agent_id} is paused by the owner.", False
272
+ if not goal:
273
+ return "Refused: the task was empty.", False
274
+ if delegations >= MAX_DELEGATIONS_PER_TASK:
275
+ return "Refused: delegation limit for this task reached.", False
276
+ if created_today >= DAILY_TASK_CAP:
277
+ return "Refused: the team's daily task limit is reached.", False
278
+ db.add(host.Task(id=uuid.uuid4(), organization_id=parent.organization_id,
279
+ agent_id=agent_id, parent_id=parent.id, thread=parent.thread,
280
+ goal=goal, status="queued", state=[], attempts=0,
281
+ origin=parent.origin))
282
+ return f"Queued for {host.agent(agent_id).name}.", True
283
+
284
+
285
+ async def _ancestors(host, db, task: Any) -> list[str]:
286
+ """Agents up the hand-off chain, nearest first: they are all waiting on this task."""
287
+ Task = host.Task
288
+ out, cur = [], task
289
+ while cur.parent_id and len(out) < MAX_ASK_DEPTH + 3:
290
+ cur = (await db.execute(select(Task).where(
291
+ Task.id == cur.parent_id, Task.organization_id == task.organization_id
292
+ ))).scalar_one_or_none()
293
+ if cur is None:
294
+ break
295
+ out.append(cur.agent_id)
296
+ return out
297
+
298
+
299
+ async def _ask_agent(host, db, parent: Any, target: str, question: str, states: dict,
300
+ delegations: int, created_today: int,
301
+ state: gate.AgentState) -> tuple[Optional[dict], bool]:
302
+ """Queue a teammate to answer a question and park the parent until they do.
303
+
304
+ Returns (pending, queued). The child is an ordinary task with parent_id set, so the
305
+ sweeper runs it; closing the loop is answer_ask() when it finishes. A refusal comes back
306
+ as an ask_failed pending so the model is told now and can try another route.
307
+ """
308
+ if not question.strip():
309
+ return None, False
310
+ if states.get(target, host.default_state()).paused:
311
+ return {"kind": "ask_failed", "agent": target,
312
+ "error": f"{host.agent(target).name} is paused by the owner."}, False
313
+ if delegations >= MAX_DELEGATIONS_PER_TASK:
314
+ return {"kind": "ask_failed", "agent": target,
315
+ "error": "the hand-off limit for this task is reached"}, False
316
+ if created_today >= DAILY_TASK_CAP:
317
+ return {"kind": "ask_failed", "agent": target,
318
+ "error": "the team's daily task limit is reached"}, False
319
+ if state.paused:
320
+ return None, False
321
+ child = host.Task(id=uuid.uuid4(), organization_id=parent.organization_id,
322
+ agent_id=target, parent_id=parent.id, thread=parent.thread,
323
+ goal=question, status="queued", state=[], attempts=0,
324
+ origin=parent.origin)
325
+ db.add(child)
326
+ return {"kind": "ask_agent", "tool_call_id": "", "agent": target,
327
+ "child_id": str(child.id), "question": question}, True
328
+
329
+
330
+ async def answer_ask(host, db, child: Any, text: str) -> Optional[Any]:
331
+ """Deliver a finished child's answer to the parent waiting on it, and wake the parent.
332
+
333
+ Returns the parent if it was waiting, so the caller can re-queue it; None if nobody is
334
+ waiting (a stale child, or the owner answered first). The parent's saved state already
335
+ holds the tool_call placeholder, so the answer lands on the exact call the model made.
336
+ """
337
+ if not child.parent_id:
338
+ return None
339
+ Task = host.Task
340
+ parent = (await db.execute(select(Task).where(
341
+ Task.id == child.parent_id, Task.organization_id == child.organization_id
342
+ ))).scalar_one_or_none()
343
+ if parent is None or parent.status != "waiting":
344
+ return None
345
+ pending = parent.pending or {}
346
+ if pending.get("kind") != "ask_agent" or pending.get("child_id") != str(child.id):
347
+ return None # the parent moved on, or was already answered
348
+ parent.state = [*(parent.state or []),
349
+ {"role": "tool", "tool_call_id": pending.get("tool_call_id", ""),
350
+ "content": f"{host.agent(child.agent_id).name} answered: {text or 'no answer'}"}]
351
+ parent.pending, parent.status, parent.attempts = None, "queued", 0
352
+ return parent
353
+
354
+
355
+ async def _finish(host, db, task: Any, history: list, text: str, actor: str,
356
+ shown: list) -> str:
357
+ history.append({"role": "assistant", "content": text})
358
+ task.state, task.pending, task.status = history, None, DONE
359
+ await host.finish(db, task, text or "Done.", list(shown), actor)
360
+ return DONE
361
+
362
+
363
+ async def park(host, db, task: Any, history: list, pending: dict, name: str, actor: str,
364
+ channel: Optional[str] = None, now: Optional[datetime] = None) -> str:
365
+ now = now or datetime.now(timezone.utc)
366
+ # Waiting on a teammate is not the owner's business: no code, no text, no card. The
367
+ # conversation in the thread already shows who was asked.
368
+ internal = pending["kind"] == "ask_agent"
369
+ pending = {**pending, "parked_at": now.isoformat()}
370
+ if not internal:
371
+ pending = {**pending, **(await host.owner_fields(db, task))}
372
+ task.state, task.pending, task.status = history, pending, WAITING
373
+ if pending["kind"] == "approval":
374
+ body, summary = describe_pending(pending), \
375
+ f"{name} wants to run {pending_tool(pending)['tool']}."
376
+ elif internal:
377
+ asked = host.agent(pending["agent"]).name
378
+ body, summary = f"Asked {asked}: {pending['question']}", f"{name} is waiting on {asked}."
379
+ else:
380
+ body, summary = pending["question"], f"{name} has a question for you."
381
+ host.post_message(db, organization_id=task.organization_id, thread=task.thread,
382
+ sender=task.agent_id, body=body, kind=pending["kind"], task_id=task.id)
383
+ await db.flush()
384
+ if internal:
385
+ return WAITING # a teammate is answering; telling the owner would be noise
386
+ await host.notify_parked(db, task, pending, name, actor, channel, summary, body)
387
+ return WAITING
388
+
389
+
390
+ def stalled(history: list, repeats: int = STALL_REPEATS) -> bool:
391
+ """The last `repeats` model turns asked for exactly the same tool calls."""
392
+ calls = [m["tool_calls"] for m in history
393
+ if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")]
394
+ if len(calls) < repeats:
395
+ return False
396
+
397
+ def sig(tool_calls: list) -> list:
398
+ return sorted((c.get("function", {}).get("name"), c.get("function", {}).get("arguments"))
399
+ for c in tool_calls)
400
+ last = [sig(c) for c in calls[-repeats:]]
401
+ return all(s == last[0] for s in last)
402
+
403
+
404
+ def too_long(history: list) -> bool:
405
+ return len(json.dumps(seen(history), default=str)) > MAX_HISTORY_CHARS
406
+
407
+
408
+ # A long run is mostly old tool results (97% of the longest runs, 2026-11-04) the model has
409
+ # already acted on: those are cut to their opening lines before the run is made to wrap up.
410
+ KEEP_RESULTS = 6 # the latest results stay whole
411
+ RESULT_HEAD = 500
412
+ CLEARED = "\n[The rest was cleared to save room. Call the tool again if you need it.]"
413
+
414
+
415
+ def compact(history: list) -> list:
416
+ tools_at = [i for i, m in enumerate(history) if isinstance(m, dict) and m.get("role") == "tool"]
417
+ old = set(tools_at[:-KEEP_RESULTS])
418
+
419
+ def cut(msg: dict) -> dict:
420
+ content = msg.get("content")
421
+ if isinstance(content, str) and len(content) > RESULT_HEAD + len(CLEARED):
422
+ return {**msg, "content": content[:RESULT_HEAD] + CLEARED}
423
+ return msg
424
+ return [cut(m) if i in old else m for i, m in enumerate(history)]
425
+
426
+
427
+ # ── Pictures a tool handed the model (taskwarden.tools.look): shown in the next request only ──
428
+ MAX_LOOKS = 4
429
+ LOOK_TEXT = "The pictures your last tool call returned:"
430
+ LOOK_GONE = "(pictures shown to you earlier)"
431
+ # Model families that read pictures. A text-only model gets the URLs in words.
432
+ # ponytail: a name check; a capability flag when a fifth family needs it.
433
+ SEES = ("deepseek", "gpt", "claude", "gemini")
434
+
435
+
436
+ def sees_pictures(model: str) -> bool:
437
+ return any(family in (model or "").lower() for family in SEES)
438
+
439
+
440
+ def seen(history: list) -> list:
441
+ """The history with every picture replaced by a line: a picture is shown once, in the
442
+ request right after the tool that returned it, and never stored (it is large, and a
443
+ signed URL dies)."""
444
+ out = []
445
+ for msg in history:
446
+ content = msg.get("content") if isinstance(msg, dict) else None
447
+ if isinstance(content, list) and any(p.get("type") == "image_url" for p in content
448
+ if isinstance(p, dict)):
449
+ msg = {**msg, "content": LOOK_GONE}
450
+ out.append(msg)
451
+ return out
452
+
453
+
454
+ async def looks(host, images: list, model: str) -> Optional[dict]:
455
+ """One user message carrying the pictures, after the round's tool messages."""
456
+ if not images:
457
+ return None
458
+ if not sees_pictures(model):
459
+ urls = [i for i in images if isinstance(i, str)]
460
+ return {"role": "user", "content": "Your model can't see pictures. Their addresses: "
461
+ + (", ".join(urls) or "none")} if urls else None
462
+ parts: list[dict] = [{"type": "text", "text": LOOK_TEXT}]
463
+ for image in images[:MAX_LOOKS]:
464
+ if url := await host.picture_url(image):
465
+ parts.append({"type": "image_url", "image_url": {"url": url}})
466
+ return {"role": "user", "content": parts} if len(parts) > 1 else None
467
+
468
+
469
+ async def run_task(host, db, task: Any, *, actor: str = "task",
470
+ llm: Optional[tuple[Any, str]] = None, now: Optional[datetime] = None,
471
+ channel: Optional[str] = None, budget_seconds: Optional[float] = None) -> str:
472
+ """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."""
474
+ started = time.monotonic()
475
+ now = now or datetime.now(timezone.utc)
476
+ org = task.organization_id
477
+ agent = host.agent(task.agent_id)
478
+ if await host.refuse_run(db, task, actor):
479
+ task.status = "failed"
480
+ await db.commit()
481
+ return task.status
482
+ self_started = host.self_started(task)
483
+ ctx, company = await host.context(db, org)
484
+ ctx.self_started = self_started
485
+ states = await host.agent_states(db, org)
486
+ state = states.get(agent.id, host.default_state())
487
+ name = state.display_name or agent.name
488
+ business_type = company.get("business_type")
489
+ teammates = host.agents_for(business_type)
490
+ client, model = llm or await host.llm(db, org)
491
+
492
+ agent = host.for_business(agent, business_type) # skills it can't use here drop out
493
+ history = answer_dangling(list(task.state or [])) or [{"role": "user", "content": task.goal}]
494
+ loaded = loaded_skills(host, agent, task, history)
495
+ allowed = host.tools_of(agent, business_type) # a skill never adds a tool this list lacks
496
+ offered = (agent.folder.tools_for(loaded) if agent.folder and agent.folder.on_demand
497
+ else allowed)
498
+ # A task that never waits fixes what it finds under the owner's own rules and hands the
499
+ # rest to teammates: no question and no consult; what needs the owner goes in its report.
500
+ never_waits = host.never_waits(task)
501
+ tools = [host.tools[n].schema for n in allowed if n in offered]
502
+ if not never_waits:
503
+ tools.append(ASK_PERSON_SCHEMA)
504
+ if agent.folder and (skill_schema := agent.folder.use_skill_schema(loaded)):
505
+ tools.append(skill_schema)
506
+ tools += host.local_schemas(channel, task)
507
+ chain = await _ancestors(host, db, task)
508
+ if never_waits:
509
+ tools.append(DELEGATE_SCHEMA)
510
+ elif len(chain) < MAX_ASK_DEPTH: # deeper than this, the task answers; it never hands off again
511
+ if agent.id == host.chief:
512
+ tools += [DELEGATE_SCHEMA, ASK_TEAM_SCHEMA]
513
+ else:
514
+ tools += [ASK_TEAM_SCHEMA, ESCALATE_SCHEMA]
515
+ local_now = now.astimezone(ZoneInfo(ctx.timezone))
516
+ system = {"role": "system", "content": await host.system_prompt(
517
+ db, task, agent, name, teammates, local_now, channel, business_type, self_started, loaded)}
518
+ Task = host.Task
519
+ # Work this task already handed to someone counts, so a loop of hand-offs cannot outrun
520
+ # the caps across approval resumes and retries.
521
+ delegations = (await db.execute(select(func.count()).select_from(Task).where(
522
+ Task.parent_id == task.id))).scalar() or 0
523
+ created_today = (await db.execute(select(func.count()).select_from(Task).where(
524
+ Task.organization_id == org,
525
+ Task.created_at >= now - timedelta(days=1)))).scalar() or 0
526
+
527
+ # Did a tool actually run this turn (or hand this resumed run a result), and was it asked to go read.
528
+ grounded, nudged = fresh_results(history), False
529
+ # What this task has already sent, in every run of it: seeded from the row so a
530
+ # park-and-resume cannot send the same person the same thing again.
531
+ region = host.region(company)
532
+ sent_task: set[str] = set(task.contacted or [])
533
+ # What the agent read in this run, raw, by tool (the last read wins), and what it chose to
534
+ # show from it: posted under its answer, dropped if the run parks instead.
535
+ reads: dict[str, Any] = {}
536
+ shown: list[dict] = []
537
+ for round_no in range(MAX_ROUNDS):
538
+ if round_no and too_long(history):
539
+ history = compact(history)
540
+ if round_no and stalled(history, 2) and not any(
541
+ m.get("content") == REPEATED for m in history if isinstance(m, dict)):
542
+ history.append({"role": "user", "content": REPEATED})
543
+ if round_no and (stalled(history) or too_long(history)):
544
+ logger.warning("team_run_wrapped_up task_id=%s rounds=%s stalled=%s",
545
+ task.id, round_no, stalled(history))
546
+ break
547
+ if round_no and budget_seconds is not None and time.monotonic() - started > budget_seconds:
548
+ # Out of time but still working: save the run and queue the rest. Not a failure,
549
+ # so the failure count starts over.
550
+ task.state, task.status, task.attempts = list(history), "queued", 0
551
+ return "queued"
552
+ await host.mark(org, task.thread, agent.id, THINKING)
553
+ resp = await client.chat.completions.create(
554
+ model=model, extra_body=host.extra_body(model), messages=[system, *history],
555
+ tools=tools, tool_choice="auto")
556
+ history = seen(history) # the pictures were seen; they are not kept
557
+ msg = resp.choices[0].message
558
+ if not msg.tool_calls:
559
+ text = host.clean_text(msg.content)
560
+ if not grounded and not nudged and states_a_figure(text):
561
+ nudged = True # one nudge, never a loop
562
+ history += [{"role": "assistant", "content": text},
563
+ {"role": "user", "content": UNGROUNDED}]
564
+ task.state = list(history)
565
+ continue
566
+ if self_started and not nudged and announces_a_step(text):
567
+ nudged = True # one nudge per run, whichever it was
568
+ history += [{"role": "assistant", "content": text},
569
+ {"role": "user", "content": UNFINISHED}]
570
+ task.state = list(history)
571
+ continue
572
+ return await _finish(host, db, task, history, text, actor, shown)
573
+ history.append(assistant_turn(msg))
574
+ parked: Optional[dict] = None
575
+ awaiting: list[dict] = [] # every call in this turn that needs the owner, as one batch
576
+ sent_round: set[str] = set() # one message per person per response, reworded or not
577
+ pictures: list = [] # what the round's tools handed the model to look at
578
+ for tc in msg.tool_calls:
579
+ if parked is not None:
580
+ history.append(tool_msg(tc.id, "Skipped: waiting on the owner for an "
581
+ "earlier step. Call it again later if still needed."))
582
+ continue
583
+ tool_name, args = tc.function.name, parse(tc.function.arguments)
584
+ if never_waits and tool_name not in {t["function"]["name"] for t in tools}:
585
+ history.append(tool_msg(tc.id, host.NEVER_WAITS_REFUSED))
586
+ continue
587
+ if tool_name == "ask_person":
588
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
589
+ risk=None, decision="allow", rule="asked_owner",
590
+ task_id=task.id, actor=actor)
591
+ parked = {"kind": "question", "tool_call_id": tc.id,
592
+ "question": str(args.get("question", "")),
593
+ "why": str(args.get("why", "")),
594
+ "choices": choices(args.get("choices"))}
595
+ continue
596
+ if tool_name in ("delegate", "consult", "escalate") and state.setting == gate.OBSERVE:
597
+ # A teammate would act for real: in practice the hand-off is only recorded.
598
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
599
+ risk=None, decision=gate.OBSERVE, rule="practice",
600
+ task_id=task.id, actor=actor)
601
+ history.append(tool_msg(tc.id, PRACTICE.format(call=describe_call(tool_name, args))))
602
+ continue
603
+ if tool_name == "delegate" and agent.id == host.chief:
604
+ if state.paused:
605
+ out, queued, rule = "Refused: agent_paused.", False, "agent_paused"
606
+ else:
607
+ await host.mark(org, task.thread, agent.id, f"delegate:{args.get('agent', '')}")
608
+ out, queued = await _delegate(host, db, task, args, teammates, states,
609
+ delegations, created_today)
610
+ if queued:
611
+ created_today += 1
612
+ rule = "delegated" if queued else "delegate_refused"
613
+ delegations += int(queued)
614
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
615
+ risk=None, decision="allow" if queued else "deny",
616
+ rule=rule, task_id=task.id, actor=actor)
617
+ history.append(tool_msg(tc.id, out))
618
+ continue
619
+ if tool_name in ("consult", "escalate"):
620
+ # Agent-to-agent: a teammate answers, the owner is not involved unless the
621
+ # team cannot settle it (the model then uses ask_person).
622
+ target = host.chief if tool_name == "escalate" else str(args.get("agent", ""))
623
+ question = str(args.get("question", "")).strip()
624
+ known = [a.id for a in teammates if a.id != agent.id and a.id not in chain]
625
+ if target not in known:
626
+ out = ("Refused: escalate is for specialists to bring a decision to the "
627
+ "Chief." if tool_name == "escalate" and agent.id == host.chief
628
+ else f"Refused: {target} is already waiting on this task. Finish it "
629
+ f"yourself, or say what is missing." if target in chain
630
+ else f"Refused: '{target}' is not a teammate you can ask.")
631
+ history.append(tool_msg(tc.id, out))
632
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
633
+ risk=None, decision="deny", rule="ask_target_unknown",
634
+ task_id=task.id, actor=actor)
635
+ continue
636
+ await host.mark(org, task.thread, agent.id, f"{tool_name}:{target}")
637
+ pending_ask, queued = await _ask_agent(
638
+ host, db, task, target, question, states, delegations, created_today, state)
639
+ if queued:
640
+ created_today += 1
641
+ delegations += int(queued)
642
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
643
+ risk=None, decision="allow" if queued else "deny",
644
+ rule=f"{tool_name}d" if queued else f"{tool_name}_refused",
645
+ task_id=task.id, actor=actor)
646
+ if pending_ask is not None and pending_ask["kind"] == "ask_failed":
647
+ history.append(tool_msg(tc.id, f"Refused: {pending_ask['error']}."))
648
+ continue
649
+ if not queued:
650
+ history.append(tool_msg(tc.id, "Refused: agent_paused."))
651
+ continue
652
+ pending_ask["tool_call_id"] = tc.id
653
+ parked = pending_ask # wait for the teammate, not the owner
654
+ continue
655
+ local = await host.local_call(db, org, tool_name, args, reads, shown)
656
+ if local is not None:
657
+ history.append(tool_msg(tc.id, local))
658
+ continue
659
+ if tool_name == USE_SKILL and agent.folder is not None:
660
+ out, added = host.use_skill(agent, args, loaded)
661
+ have = {t["function"]["name"] for t in tools}
662
+ tools[:0] = [host.tools[n].schema for n in allowed if n in added and n not in have]
663
+ history.append(tool_msg(tc.id, out))
664
+ continue
665
+ tool = host.tools.get(tool_name)
666
+ decision = host.decide(agent, tool, state, self_started=self_started)
667
+ target = next((str(args[k])[:320] for k in ("to", "phone_number", "contact_info")
668
+ if isinstance(args, dict) and args.get(k)), None)
669
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
670
+ risk=tool.risk if tool else None, decision=decision.outcome,
671
+ rule=decision.rule, task_id=task.id, actor=actor, target=target)
672
+ reach = host.reach_key(tool_name, args, region)
673
+ sent = host.sent_key(tool_name, args, region)
674
+ if decision.outcome in (gate.ASK, gate.ALLOW) and (
675
+ (sent is not None and sent in sent_task)
676
+ or (reach is not None and reach in sent_round)):
677
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
678
+ risk=tool.risk if tool else None, decision="deny",
679
+ rule="duplicate_send", task_id=task.id, actor=actor)
680
+ history.append(tool_msg(tc.id, "Refused: you already messaged this person in "
681
+ "this turn. Do not send it again."))
682
+ continue
683
+ if (decision.outcome in (gate.ASK, gate.ALLOW) and tool is not None
684
+ and tool.check is not None):
685
+ refusal = await host.precheck(tool, ctx, args)
686
+ if refusal:
687
+ # Can't work as it stands: say why now, instead of an approval card the
688
+ # owner would approve only to see it fail, or a send that should not go.
689
+ host.audit(db, organization_id=org, agent_id=agent.id, tool=tool_name,
690
+ risk=tool.risk, decision="deny", rule="precheck",
691
+ task_id=task.id, actor=actor)
692
+ history.append(tool_msg(tc.id, refusal))
693
+ continue
694
+ if decision.outcome == gate.OBSERVE:
695
+ history.append(tool_msg(tc.id, PRACTICE.format(
696
+ call=describe_call(tool_name, host.clean_args(args)))))
697
+ continue
698
+ if decision.outcome == gate.ASK and never_waits:
699
+ history.append(tool_msg(tc.id, host.NEVER_WAITS_NEEDS_OWNER))
700
+ continue
701
+ if decision.outcome == gate.ASK:
702
+ if reach is not None:
703
+ # Parked: the owner's one click runs the whole batch, so a second send to
704
+ # the same person would be a second message, not a retry. Not recorded as
705
+ # sent -- a declined batch sent nothing.
706
+ sent_round.add(reach)
707
+ # One owner click on the whole batch beats N clicks. A tool message is still
708
+ # owed for every call id, or the next request carries an unanswered call.
709
+ awaiting.append({"tool_call_id": tc.id, "tool": tool_name,
710
+ "args": host.clean_args(args)})
711
+ continue
712
+ if decision.outcome == gate.ALLOW:
713
+ await host.mark(org, task.thread, agent.id, tool_name)
714
+ if sent is not None:
715
+ # Saved before it runs: a crash mid-send must not lead to a second send.
716
+ task.state = [*history, tool_msg(tc.id, MAYBE_SENT)]
717
+ task.contacted = sorted(sent_task | {sent})
718
+ await db.commit()
719
+ with capture_looks() as looked:
720
+ out, ok, raw = await host.execute(tool, ctx, args)
721
+ pictures += looked
722
+ if tool.risk == READ:
723
+ if ok and raw is not None:
724
+ reads[tool_name] = raw
725
+ else: # a read that failed must not leave an older one to be shown
726
+ reads.pop(tool_name, None)
727
+ if ok:
728
+ grounded = True
729
+ if ok is not False: # None: stopped at its timeout, it may have gone out
730
+ if reach is not None:
731
+ # Only a send that (maybe) went out counts: a rejected one leaves
732
+ # the retry open, which is the whole point of retrying.
733
+ sent_round.add(reach)
734
+ if sent is not None:
735
+ sent_task.add(sent)
736
+ if not ok:
737
+ host.audit(db, organization_id=org, agent_id=agent.id,
738
+ tool=tool_name, risk=tool.risk, decision="error",
739
+ rule="handler_failed", task_id=task.id, actor=actor)
740
+ else:
741
+ out = f"Refused: {decision.rule}."
742
+ history.append(tool_msg(tc.id, out))
743
+ if parked is None and not awaiting and (look := await looks(host, pictures, model)):
744
+ # Only when the run goes on: a parked run may resume after the pictures expired.
745
+ history.append(look)
746
+ task.state, task.contacted = list(history), sorted(sent_task)
747
+ if parked is None and not awaiting:
748
+ await db.commit() # the round's progress survives a restart
749
+ if parked is not None:
750
+ # A question wins over a batch in the same response: the answer may change what to
751
+ # send. Each held call still needs its tool message; the model proposes it again.
752
+ for call in awaiting:
753
+ history.append(tool_msg(call["tool_call_id"],
754
+ "Not sent: you asked a question first. Propose this "
755
+ "again once you have the answer."))
756
+ task.state = list(history)
757
+ return await park(host, db, task, history, parked, name, actor, channel, now)
758
+ if awaiting:
759
+ batch = awaiting[0] if len(awaiting) == 1 else {"kind": "approval", "batch": awaiting}
760
+ batch.setdefault("kind", "approval")
761
+ for call in awaiting:
762
+ # Placeholder until the owner decides; the decision overwrites these.
763
+ history.append(tool_msg(call["tool_call_id"], "Waiting for your approval."))
764
+ task.state = list(history)
765
+ return await park(host, db, task, history, batch, name, actor, channel, now)
766
+
767
+ wrap_up = {"role": "user", "content": WRAP_UP}
768
+ resp = await client.chat.completions.create(model=model, extra_body=host.extra_body(model),
769
+ messages=[system, *history, wrap_up])
770
+ text = resp.choices[0].message.content or ""
771
+ return await _finish(host, db, task, history, host.clean_text(text) or host.OUT_OF_STEPS,
772
+ actor, shown)