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/__init__.py +9 -0
- taskwarden/decisions.py +168 -0
- taskwarden/folders.py +154 -0
- taskwarden/gate.py +64 -0
- taskwarden/host.py +252 -0
- taskwarden/loop.py +772 -0
- taskwarden/models.py +55 -0
- taskwarden/replies.py +44 -0
- taskwarden/sweeper.py +154 -0
- taskwarden/tools.py +190 -0
- taskwarden-0.1.0.dist-info/METADATA +170 -0
- taskwarden-0.1.0.dist-info/RECORD +14 -0
- taskwarden-0.1.0.dist-info/WHEEL +4 -0
- taskwarden-0.1.0.dist-info/licenses/LICENSE +202 -0
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)
|