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 ADDED
@@ -0,0 +1,9 @@
1
+ """taskwarden: a durable, owner-approved AI team for a business.
2
+
3
+ Modules: loop (one task's run), gate (who may run what), tools (Tool, risk tiers, running a
4
+ call), decisions (the owner's approve / edit / decline), sweeper (background runs), replies
5
+ (a customer's reply wakes the task), folders (agents as AGENT.md + skills), models (table
6
+ columns), host (what the app supplies). See README.md.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,168 @@
1
+ """The owner's decision on a parked task: approve, decline, revise (they wrote a change instead
2
+ of approving), or answer (a question). Claimed first, then carried out.
3
+
4
+ `claim` is quick and committed, so an app can answer the owner at once and `apply` in the
5
+ background (approved actions, texts, emails, campaigns, can take a minute).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Optional
11
+
12
+ from sqlalchemy import select, update
13
+
14
+ from .loop import MAYBE_SENT, pending_calls, tool_msg
15
+
16
+ # What a parked call reads when the owner wrote in the chat instead of approving it.
17
+ REVISE_RESULT = ("Not done yet: the owner replied instead of approving: \"{change}\". If it "
18
+ "changes this action, make the change and ask for approval again. If it is about "
19
+ "something else, do that, and ask for this action again only if it still makes sense.")
20
+ # A parked call whose tool no longer exists when the owner decides.
21
+ GONE = "Not run: this action no longer exists. Say what you meant to do another way."
22
+ # What the model reads before the result of a call the owner edited, so its report names
23
+ # what really went out.
24
+ EDITED = "The owner changed {fields} before approving, and it ran as changed. "
25
+ APPROVAL_DECISIONS = ("approve", "decline", "revise")
26
+
27
+
28
+ def edited(args: dict, edits: Optional[dict]) -> tuple[dict, list[str]]:
29
+ """The call's arguments with the owner's edits, and which ones changed. Only text the call
30
+ already had can be rewritten: no new argument, no change of type."""
31
+ changed = sorted(k for k, v in (edits or {}).items()
32
+ if isinstance(v, str) and isinstance(args.get(k), str) and v != args[k])
33
+ return {**args, **{k: edits[k] for k in changed}}, changed
34
+
35
+
36
+ def answered(state: list, results: list[dict]) -> list:
37
+ """The owner's decision, standing in for the placeholder each parked call left.
38
+
39
+ The loop parks a "Waiting for your approval." tool message per call id, so every call is
40
+ already answered in the saved state. A second message for the same tool_call_id would be a
41
+ duplicate answer, which providers reject, so replace in place and only append a result
42
+ whose placeholder is missing.
43
+ """
44
+ by_id = {r["tool_call_id"]: r for r in results}
45
+ out = []
46
+ for msg in state:
47
+ cid = msg.get("tool_call_id") if isinstance(msg, dict) else None
48
+ if cid is not None and cid in by_id:
49
+ out.append(by_id.pop(cid)) # the placeholder for this call is replaced, not joined
50
+ else:
51
+ out.append(msg)
52
+ return out + list(by_id.values()) # any answer whose placeholder was missing
53
+
54
+
55
+ async def claim(host, db, organization_id: str, task_id: Any, decision: str):
56
+ """Check the decision fits the parked task and claim it ("running"), committed. The task,
57
+ or why not: "not_found" | "conflict" | "invalid"."""
58
+ Task = host.Task
59
+ task = (await db.execute(select(Task).where(
60
+ Task.id == task_id, Task.organization_id == organization_id
61
+ ))).scalar_one_or_none()
62
+ if task is None:
63
+ return "not_found"
64
+ if task.status != "waiting":
65
+ return "conflict"
66
+ pending = task.pending or {}
67
+ if pending.get("kind") == "ask_agent":
68
+ return "not_found" # nothing for the owner to decide: a teammate is answering this one
69
+ if (pending.get("kind") == "approval") != (decision in APPROVAL_DECISIONS):
70
+ return "invalid"
71
+ claimed = (await db.execute(update(Task).where(
72
+ Task.id == task_id, Task.status == "waiting"
73
+ ).values(status="running").returning(Task.id))).scalar_one_or_none()
74
+ if claimed is None:
75
+ return "conflict"
76
+ task.status = "running"
77
+ await db.commit()
78
+ return task
79
+
80
+
81
+ async def apply(host, db, organization_id: str, task: Any, decision: str, *,
82
+ note: Optional[str] = None, answer: Optional[str] = None, resume: bool = False,
83
+ declined_calls: Optional[list[str]] = None,
84
+ edited_args: Optional[dict[str, dict]] = None) -> None:
85
+ """Carry out a claimed decision: run what was approved (or record the decline, the change
86
+ asked for, or the answer), hand the results to the task and post the decision to its
87
+ thread. `task` is the claimed task, or its id from another session. `declined_calls`: on an
88
+ approve, the batch's calls (by tool_call_id) the owner left out; `edited_args`, the text the
89
+ owner rewrote before approving, by tool_call_id then argument (see `edited`). `resume`: the caller runs
90
+ the task now, so it stays "running" instead of going back to the queue."""
91
+ Task = host.Task
92
+ if not isinstance(task, Task):
93
+ task = (await db.execute(select(Task).where(
94
+ Task.id == task, Task.organization_id == organization_id
95
+ ))).scalar_one_or_none()
96
+ if task is None: # deleted between the claim and now
97
+ return
98
+ pending = task.pending or {}
99
+ is_approval = pending.get("kind") == "approval"
100
+ calls = pending_calls(pending) if is_approval else []
101
+ skip = set(declined_calls or ())
102
+ runs = [decision == "approve" and c.get("tool_call_id", "") not in skip for c in calls]
103
+ # What runs is what the owner approved, their edits included.
104
+ changes = []
105
+ for i, (c, run) in enumerate(zip(calls, runs)):
106
+ args, changed = edited(c.get("args") or {}, (edited_args or {}).get(c.get("tool_call_id", "")))
107
+ calls[i] = {**c, "args": args} if run and changed else c
108
+ changes.append(changed if run else [])
109
+ results: list[dict] = []
110
+ if any(runs):
111
+ ctx, company = await host.context(db, organization_id)
112
+ region = host.region(company)
113
+ sent = set(task.contacted or [])
114
+ for call, run, changed in zip(calls, runs, changes):
115
+ tool = host.tools.get(call["tool"])
116
+ if tool is None: # renamed or removed while it waited for the owner
117
+ results.append(tool_msg(call.get("tool_call_id", ""), GONE))
118
+ continue
119
+ if run:
120
+ key = host.sent_key(call["tool"], call.get("args") or {}, region)
121
+ if key is not None:
122
+ # Saved before it runs, as in the loop: a crash mid-send must not lead to a
123
+ # second send when the task is retried.
124
+ task.state = answered(task.state or [], [tool_msg(call.get("tool_call_id", ""), MAYBE_SENT)])
125
+ task.contacted = sorted(sent | {key})
126
+ await db.commit()
127
+ out, ok, _ = await host.execute(tool, ctx, call.get("args") or {})
128
+ host.audit(db, organization_id=organization_id, agent_id=task.agent_id,
129
+ tool=call["tool"], risk=tool.risk,
130
+ decision="approved" if ok else "error",
131
+ rule="owner_edited" if changed else "owner_approved",
132
+ task_id=task.id, actor="owner")
133
+ if changed:
134
+ out = EDITED.format(fields=", ".join(changed)) + out
135
+ if ok is not False: # None: stopped at its timeout, it may have gone out
136
+ # What the owner approved is now sent: the resumed run must not repeat it.
137
+ if key is not None:
138
+ sent.add(key)
139
+ else:
140
+ out = (REVISE_RESULT.format(change=answer or "") if decision == "revise"
141
+ else "Owner declined." + (f" Note: {note}" if note else ""))
142
+ host.audit(db, organization_id=organization_id, agent_id=task.agent_id,
143
+ tool=call["tool"], risk=tool.risk, decision="declined",
144
+ rule="owner_revised" if decision == "revise" else "owner_declined",
145
+ task_id=task.id, actor="owner")
146
+ results.append({"role": "tool", "tool_call_id": call.get("tool_call_id", ""), "content": out})
147
+ if any(runs):
148
+ task.contacted = sorted(sent)
149
+ if not is_approval:
150
+ results = [{"role": "tool", "tool_call_id": pending.get("tool_call_id", ""),
151
+ "content": f"Owner answered: {answer or note or ''}"}]
152
+ echo, kind = answer or note or "", "text"
153
+ if is_approval and decision != "revise":
154
+ # What was decided, as data: the app words it for the owner, and the team's history
155
+ # reads it back as one line.
156
+ partial = any(runs) and not all(runs)
157
+ decided = [{"tool": c["tool"], "args": c.get("args") or {},
158
+ **({"declined": True} if partial and not run else {})}
159
+ for c, run in zip(calls, runs)]
160
+ echo, kind = json.dumps({"decision": "approved" if any(runs) else "declined",
161
+ "calls": decided, "note": note or None,
162
+ "names": await host.decision_names(db, organization_id, decided)}), "decision"
163
+
164
+ task.state = answered(task.state or [], results)
165
+ task.pending, task.status, task.attempts = None, "running" if resume else "queued", 0
166
+ host.post_message(db, organization_id=organization_id, thread=task.thread,
167
+ sender="owner", body=echo, kind=kind, task_id=task.id)
168
+ await db.commit()
taskwarden/folders.py ADDED
@@ -0,0 +1,154 @@
1
+ """Agents as folders: `<agents>/<id>/AGENT.md` and `<agents>/<id>/skills/<name>/SKILL.md`.
2
+
3
+ An agent is its core role (AGENT.md) plus one skill per job, each with its own words, tools and
4
+ evals. The app turns a folder into its own agent definition; this module only reads and checks
5
+ files. A bad folder fails when it is loaded, never at the first task that reads it. Whether the
6
+ tool names exist is the app's check: only it knows its tools.
7
+
8
+ `business_types` in AGENT.md is a list, null (every type), or the name of a group the app
9
+ expands; `load_agent(folder, groups=...)` names the groups it accepts.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import yaml
18
+
19
+ _AGENT_KEYS = {"id", "name", "title", "aliases", "business_types", "tools", "skills", "agenda",
20
+ "skills_on_demand"}
21
+ _SKILL_KEYS = {"name", "description", "tools", "requires", "evals"}
22
+ _AGENDA_KEYS = {"cadence", "weekday", "goal", "skills"}
23
+
24
+ USE_SKILL = "use_skill"
25
+ SKILLS_HINT = ("Your skills: each is how you do one job. Before you do that job, load it with "
26
+ "use_skill; it gives you its instructions and its tools. Load the ones the task "
27
+ "needs, all in one response.")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Skill:
32
+ name: str
33
+ description: str
34
+ tools: tuple[str, ...]
35
+ text: str
36
+ evals: tuple[str, ...] = ()
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AgentFolder:
41
+ id: str
42
+ name: str
43
+ title: str
44
+ core: str
45
+ tools: tuple[str, ...] # the core's own; see all_tools
46
+ skills: tuple[Skill, ...]
47
+ aliases: tuple[str, ...] = ()
48
+ # None = every type; a list; or a named group the roster expands ("field_work").
49
+ business_types: Optional[tuple[str, ...] | str] = None
50
+ agenda: Optional[dict] = None # {cadence, weekday, goal, skills: preloaded}
51
+ # Step 2: the prompt lists the skills and use_skill loads one. Off: every skill is in
52
+ # the role (step 1). Switched on per agent, only when its evals hold.
53
+ on_demand: bool = False
54
+
55
+ @property
56
+ def role(self) -> str:
57
+ """Step 1: every skill is always loaded, the core first, then the skills in order."""
58
+ return " ".join([self.core, *(s.text for s in self.skills)])
59
+
60
+ @property
61
+ def all_tools(self) -> tuple[str, ...]:
62
+ return self.tools_for({s.name for s in self.skills})
63
+
64
+ def tools_for(self, loaded: set[str]) -> tuple[str, ...]:
65
+ """The core's tools, then each loaded skill's, in skill order, once each."""
66
+ seen: dict[str, None] = dict.fromkeys(self.tools)
67
+ for skill in self.skills:
68
+ if skill.name in loaded:
69
+ seen.update(dict.fromkeys(skill.tools))
70
+ return tuple(seen)
71
+
72
+ def prompt(self, loaded: set[str]) -> str:
73
+ """The role an on-demand agent starts with: its core, the skills already loaded, and
74
+ one line for each of the others."""
75
+ rest = [s for s in self.skills if s.name not in loaded]
76
+ parts = [self.core, *(s.text for s in self.skills if s.name in loaded)]
77
+ if rest:
78
+ parts.append(SKILLS_HINT + " " + " ".join(f"{s.name}: {s.description}"
79
+ for s in rest))
80
+ return " ".join(parts)
81
+
82
+ def skill(self, name: str) -> Optional[Skill]:
83
+ return next((s for s in self.skills if s.name == name), None)
84
+
85
+ def use_skill_schema(self, loaded: set[str]) -> Optional[dict]:
86
+ names = [s.name for s in self.skills if s.name not in loaded]
87
+ if not (self.on_demand and names):
88
+ return None
89
+ return {"type": "function", "function": {
90
+ "name": USE_SKILL,
91
+ "description": "Load one of your skills: its instructions and its tools.",
92
+ "parameters": {"type": "object", "properties": {
93
+ "name": {"type": "string", "enum": names}}, "required": ["name"]}}}
94
+
95
+
96
+ def _read(path: Path, keys: set[str]) -> tuple[dict, str]:
97
+ """(frontmatter, body as one line of text). The body is wrapped for reading; the words
98
+ are what counts, so line breaks become spaces."""
99
+ raw = path.read_text(encoding="utf-8")
100
+ if not raw.startswith("---\n"):
101
+ raise ValueError(f"{path}: starts with a --- frontmatter block")
102
+ head, _, body = raw[4:].partition("\n---\n")
103
+ meta = yaml.safe_load(head) or {}
104
+ if unknown := set(meta) - keys:
105
+ raise ValueError(f"{path}: unknown keys {sorted(unknown)}")
106
+ text = " ".join(body.split())
107
+ if not text:
108
+ raise ValueError(f"{path}: has no instructions")
109
+ return meta, text
110
+
111
+
112
+ def _names(path: Path, meta: dict, key: str) -> tuple[str, ...]:
113
+ value = meta.get(key) or []
114
+ if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
115
+ raise ValueError(f"{path}: {key} is a list of names")
116
+ return tuple(value)
117
+
118
+
119
+ def load_skill(path: Path) -> Skill:
120
+ meta, text = _read(path, _SKILL_KEYS)
121
+ if meta.get("name") != path.parent.name:
122
+ raise ValueError(f"{path}: name must be {path.parent.name!r}")
123
+ if not (meta.get("description") or "").strip():
124
+ raise ValueError(f"{path}: needs a description")
125
+ return Skill(name=meta["name"], description=" ".join(meta["description"].split()),
126
+ tools=_names(path, meta, "tools"), text=text, evals=_names(path, meta, "evals"))
127
+
128
+
129
+ def load_agent(folder: Path, groups: tuple[str, ...] = ()) -> AgentFolder:
130
+ path = folder / "AGENT.md"
131
+ meta, core = _read(path, _AGENT_KEYS)
132
+ if meta.get("id") != folder.name:
133
+ raise ValueError(f"{path}: id must be {folder.name!r}")
134
+ for key in ("name", "title"):
135
+ if not (meta.get(key) or "").strip():
136
+ raise ValueError(f"{path}: needs a {key}")
137
+ skills = tuple(load_skill(folder / "skills" / name / "SKILL.md")
138
+ for name in _names(path, meta, "skills"))
139
+ agenda = meta.get("agenda")
140
+ if agenda is not None and (not isinstance(agenda, dict) or set(agenda) - _AGENDA_KEYS
141
+ or not agenda.get("goal") or not agenda.get("cadence")):
142
+ raise ValueError(f"{path}: agenda has cadence, goal and optionally weekday and skills")
143
+ if unknown := set((agenda or {}).get("skills") or []) - {s.name for s in skills}:
144
+ raise ValueError(f"{path}: agenda preloads unknown skills {sorted(unknown)}")
145
+ types = meta.get("business_types")
146
+ if isinstance(types, str) and types not in groups:
147
+ raise ValueError(f"{path}: business_types is a list, null or one of {groups}")
148
+ return AgentFolder(
149
+ id=meta["id"], name=meta["name"], title=meta["title"], core=core,
150
+ tools=_names(path, meta, "tools"), skills=skills,
151
+ aliases=_names(path, meta, "aliases"),
152
+ business_types=tuple(types) if isinstance(types, list) else types,
153
+ agenda={**agenda, "goal": " ".join(agenda["goal"].split())} if agenda else None,
154
+ on_demand=bool(meta.get("skills_on_demand")))
taskwarden/gate.py ADDED
@@ -0,0 +1,64 @@
1
+ """The action boundary: every tool call is decided before it runs.
2
+
3
+ Order, first match wins: tool not granted -> deny; agent paused -> deny; an agent in practice
4
+ ("observe") -> reads run, everything else is only recorded; a self-started task
5
+ reaching a customer or the money -> ask; remove/profile -> always ask; read/draft/routine ->
6
+ allow; contact/spend/change -> the agent's setting ("ask" | "allow"); anything else -> deny
7
+ (fail closed).
8
+
9
+ A self-started task (one the app's own schedule began, not the owner) never sends, spends or
10
+ commits the business without a click, however loose the agent's setting is: it reads and
11
+ drafts freely, then parks, and the owner's approval runs the call it prepared. `outward` names
12
+ the ROUTINE tools that still reach a customer (a text, a booking) for that rule.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any, Optional
18
+
19
+ from .tools import CHANGE, CONTACT, DRAFT, PROFILE, READ, REMOVE, ROUTINE, SPEND, Tool
20
+
21
+ ALLOW, ASK, DENY = "allow", "ask", "deny"
22
+ # The agent's practice setting, and the outcome of a call made in it: recorded, not run. A new
23
+ # agent (or a new skill) can work real requests and show what it would have done.
24
+ OBSERVE = "observe"
25
+ SELF_STARTED_NEEDS_OWNER = "self_started_needs_owner"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Decision:
30
+ outcome: str
31
+ rule: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AgentState:
36
+ """The owner's settings for one agent. An app adds its own fields by subclassing."""
37
+ setting: str = ASK
38
+ paused: bool = False
39
+ display_name: Optional[str] = None
40
+
41
+
42
+ def decide(agent: Any, tool: Optional[Tool], state: AgentState, *,
43
+ self_started: bool = False, outward: frozenset = frozenset()) -> Decision:
44
+ """`agent.tools` is what the agent is granted."""
45
+ if tool is None or tool.name not in agent.tools:
46
+ return Decision(DENY, "tool_not_granted")
47
+ if state.paused:
48
+ return Decision(DENY, "agent_paused")
49
+ if state.setting == OBSERVE:
50
+ return Decision(ALLOW, "safe_action") if tool.risk == READ else Decision(OBSERVE, "practice")
51
+ if self_started and (tool.risk in (CONTACT, SPEND, CHANGE) or tool.name in outward):
52
+ return Decision(ASK, SELF_STARTED_NEEDS_OWNER)
53
+ if tool.risk == REMOVE: # deletes can't be undone: always the owner's click
54
+ return Decision(ASK, "always_ask_remove")
55
+ if tool.risk == PROFILE: # the business profile changes only with the owner's click
56
+ return Decision(ASK, "always_ask_profile")
57
+ if tool.risk in (READ, DRAFT, ROUTINE):
58
+ return Decision(ALLOW, "safe_action")
59
+ if tool.risk in (CONTACT, SPEND, CHANGE):
60
+ if state.setting == ASK:
61
+ return Decision(ASK, "owner_approval_required")
62
+ if state.setting == ALLOW:
63
+ return Decision(ALLOW, "owner_allowed")
64
+ return Decision(DENY, "fail_closed")
taskwarden/host.py ADDED
@@ -0,0 +1,252 @@
1
+ """What the app supplies to the harness. taskwarden never imports the app; the app hands it a Host.
2
+
3
+ Each app writes its own (examples/shop.py is a complete one): its tables (the
4
+ `Task` and `Message` models, see taskwarden.models for the columns), its agents and tools, the
5
+ words its agents read, its model, and how its owner is told. Everything below is required
6
+ unless it has a default in `BaseHost`: subclass it and write only what your app decides.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from datetime import datetime
12
+ from types import SimpleNamespace
13
+ from typing import Any, Optional, Protocol
14
+
15
+ from . import gate
16
+ from . import tools as _tools
17
+ from .gate import AgentState, Decision
18
+ from .tools import Tool
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class Host(Protocol):
24
+ # ── Tables and the roster ──
25
+ Task: Any # SQLAlchemy model with taskwarden.models.TaskColumns
26
+ chief: str # the agent the owner talks to when they name nobody
27
+ tools: dict[str, Tool] # every tool, by name
28
+
29
+ def agent(self, agent_id: str) -> Any: ... # .id .name .title .tools .folder
30
+ def agents_for(self, business_type: Optional[str]) -> list[Any]: ...
31
+ def tools_of(self, agent: Any, business_type: Optional[str]) -> tuple[str, ...]: ...
32
+ def for_business(self, agent: Any, business_type: Optional[str]) -> Any: ...
33
+
34
+ # ── Before a run ──
35
+ async def refuse_run(self, db: Any, task: Any, actor: str) -> bool:
36
+ """True when the business can't run the team now (no credits). Tell the owner if
37
+ `actor == "owner"`; the harness fails the task."""
38
+ async def context(self, db: Any, organization_id: str) -> tuple[Any, dict]:
39
+ """(the ctx every handler gets, with .db .organization_id .timezone; the business's
40
+ facts the host itself reads back: business_type, country)."""
41
+ async def agent_states(self, db: Any, organization_id: str) -> dict[str, AgentState]: ...
42
+ def default_state(self) -> AgentState: ...
43
+ async def llm(self, db: Any, organization_id: str) -> tuple[Any, str]:
44
+ """(an OpenAI-compatible async client, model name)."""
45
+ def extra_body(self, model: str) -> Optional[dict]: ...
46
+ def clean_text(self, text: Optional[str]) -> str: ...
47
+ def self_started(self, task: Any) -> bool:
48
+ """The app's schedule started this task: nothing leaves without the owner's click."""
49
+ def never_waits(self, task: Any) -> bool:
50
+ """This task may not ask anyone or park (a morning review): what needs the owner goes
51
+ in its report."""
52
+ def preloaded_skills(self, agent: Any, task: Any) -> set[str]: ...
53
+ async def system_prompt(self, db: Any, task: Any, agent: Any, name: str, teammates: list,
54
+ now: datetime,
55
+ channel: Optional[str], business_type: Optional[str],
56
+ self_started: bool, loaded: set[str]) -> str: ...
57
+ def local_schemas(self, channel: Optional[str], task: Any) -> list[dict]:
58
+ """Tools the host answers inside the loop itself (a `show` that draws a table, say)."""
59
+ async def local_call(self, db: Any, organization_id: str, name: str, args: dict,
60
+ reads: dict, shown: list) -> Optional[str]:
61
+ """The answer to a local tool, or None when `name` is not one."""
62
+ def region(self, company: dict) -> str: ...
63
+
64
+ # ── Each call ──
65
+ def decide(self, agent: Any, tool: Optional[Tool], state: AgentState, *,
66
+ self_started: bool) -> Decision: ...
67
+ async def precheck(self, tool: Tool, ctx: Any, args: dict) -> Optional[str]:
68
+ """The tool's own check before the owner is asked. Must not raise: a check that fails
69
+ to run returns None, and the owner is asked as before."""
70
+ def clean_args(self, args: dict) -> dict:
71
+ """The arguments a parked call keeps: without the ones the server owns."""
72
+ def use_skill(self, agent: Any, args: dict, loaded: set[str]) -> tuple[str, tuple[str, ...]]:
73
+ """Load a skill: taskwarden.loop.use_skill, unless the host records it too."""
74
+ async def execute(self, tool: Tool, ctx: Any, args: dict) -> tuple[str, Optional[bool], Any]:
75
+ """(text for the model, ok, the raw result of a read or None). ok None: unknown (a
76
+ call stopped at its timeout), counted as sent."""
77
+ def audit(self, db: Any, *, organization_id: str, agent_id: str, tool: str,
78
+ risk: Optional[str], decision: str, rule: str, task_id: Any, actor: str,
79
+ target: Optional[str] = None) -> None: ...
80
+ def reach_key(self, tool_name: str, args: dict, region: str) -> Optional[str]:
81
+ """`tool|recipient` when the call reaches one person: one message per person per turn."""
82
+ def sent_key(self, tool_name: str, args: dict, region: str) -> Optional[str]:
83
+ """`tool|recipient|body`: never the same message to the same person twice in a task."""
84
+ async def picture_url(self, image: Any) -> Optional[str]: ...
85
+ async def mark(self, organization_id: str, thread: str, agent_id: str, step: str) -> None:
86
+ """Live progress for the owner's screen; best effort."""
87
+
88
+ # ── Telling people ──
89
+ def post_message(self, db: Any, *, organization_id: str, thread: str, sender: str,
90
+ body: str, kind: str = "text", task_id: Any = None) -> Any: ...
91
+ async def finish(self, db: Any, task: Any, body: str, shown: list, actor: str) -> None:
92
+ """Post the report (and anything shown), wake a teammate waiting on it
93
+ (taskwarden.loop.answer_ask), relay background work to the owner."""
94
+ async def decision_names(self, db: Any, organization_id: str, decided: list[dict]) -> dict:
95
+ """Names for the people the decided calls reach ({"+1555...": "Hannah Lee"}): the app
96
+ shows the decision with them."""
97
+ async def owner_fields(self, db: Any, task: Any) -> dict:
98
+ """Extra fields on a park that waits for the owner (a code they can reply with, say)."""
99
+ async def notify_parked(self, db: Any, task: Any, pending: dict, name: str, actor: str,
100
+ channel: Optional[str], summary: str, body: str) -> None:
101
+ """Tell the owner something waits for them. May update task.pending."""
102
+
103
+ # Words the harness says to the model on the host's behalf.
104
+ NEVER_WAITS_REFUSED: str
105
+ NEVER_WAITS_NEEDS_OWNER: str
106
+ OUT_OF_STEPS: str
107
+
108
+
109
+ class BaseHost:
110
+ """Defaults for every member of `Host` that has a sensible one. A subclass sets `Task`,
111
+ `Message`, `chief`, `agents` (by id) and `tools`, and writes `llm` and `system_prompt`;
112
+ everything else can stay as it is until the app needs more (examples/shop.py).
113
+
114
+ What the defaults leave out: no credit check, one time zone (UTC), no live progress, no
115
+ duplicate-send guard (write `sent_key` and `reach_key` once a tool reaches people), and
116
+ the owner is told only through the chat (write `notify_parked` for push, SMS or email)."""
117
+
118
+ Task: Any
119
+ Message: Any = None # a model with taskwarden.models.MessageColumns; None posts nothing
120
+ chief: str
121
+ agents: dict[str, Any]
122
+ tools: dict[str, Tool]
123
+
124
+ NEVER_WAITS_REFUSED = "Refused: this task can't ask anyone. Put it in your report."
125
+ NEVER_WAITS_NEEDS_OWNER = ("Not done: this needs the owner, and this task can't wait for "
126
+ "them. Put it in your report.")
127
+ OUT_OF_STEPS = "I ran out of steps before finishing. Ask me to carry on."
128
+
129
+ # ── Write these ──
130
+ async def llm(self, db: Any, organization_id: str) -> tuple[Any, str]:
131
+ raise NotImplementedError("return (an OpenAI-compatible async client, model name)")
132
+
133
+ async def system_prompt(self, db, task, agent, name, teammates, now, channel,
134
+ business_type, self_started, loaded) -> str:
135
+ raise NotImplementedError("return the words the agent reads first")
136
+
137
+ # ── The roster ──
138
+ def agent(self, agent_id: str) -> Any:
139
+ return self.agents[agent_id]
140
+
141
+ def agents_for(self, business_type: Optional[str]) -> list[Any]:
142
+ return list(self.agents.values())
143
+
144
+ def tools_of(self, agent: Any, business_type: Optional[str]) -> tuple[str, ...]:
145
+ return tuple(agent.tools)
146
+
147
+ def for_business(self, agent: Any, business_type: Optional[str]) -> Any:
148
+ return agent
149
+
150
+ # ── Before a run ──
151
+ async def refuse_run(self, db, task, actor) -> bool:
152
+ return False
153
+
154
+ async def context(self, db, organization_id) -> tuple[Any, dict]:
155
+ return SimpleNamespace(db=db, organization_id=organization_id, timezone="UTC"), {}
156
+
157
+ async def agent_states(self, db, organization_id) -> dict[str, AgentState]:
158
+ return {}
159
+
160
+ def default_state(self) -> AgentState:
161
+ return AgentState()
162
+
163
+ def extra_body(self, model: str) -> Optional[dict]:
164
+ return None
165
+
166
+ def clean_text(self, text: Optional[str]) -> str:
167
+ return (text or "").strip()
168
+
169
+ def self_started(self, task) -> bool:
170
+ # A hand-off copies its parent's origin, so delegating is no way around the owner.
171
+ return task.origin != "owner"
172
+
173
+ def never_waits(self, task) -> bool:
174
+ return False
175
+
176
+ def preloaded_skills(self, agent, task) -> set[str]:
177
+ return set()
178
+
179
+ def local_schemas(self, channel, task) -> list[dict]:
180
+ return []
181
+
182
+ async def local_call(self, db, organization_id, name, args, reads, shown) -> Optional[str]:
183
+ return None
184
+
185
+ def region(self, company: dict) -> str:
186
+ return str(company.get("country") or "")
187
+
188
+ # ── Each call ──
189
+ def decide(self, agent, tool, state, *, self_started) -> Decision:
190
+ return gate.decide(agent, tool, state, self_started=self_started)
191
+
192
+ async def precheck(self, tool, ctx, args) -> Optional[str]:
193
+ if tool.check is None:
194
+ return None
195
+ try:
196
+ return await _tools.check(tool, ctx, args)
197
+ except Exception: # a check that can't run asks the owner as before
198
+ logger.exception("taskwarden_precheck_failed tool=%s", tool.name)
199
+ return None
200
+
201
+ def clean_args(self, args: dict) -> dict:
202
+ return _tools.clean_args(args)
203
+
204
+ def use_skill(self, agent, args, loaded):
205
+ from .loop import use_skill # the loop imports this module
206
+ return use_skill(agent, args, loaded)
207
+
208
+ async def execute(self, tool, ctx, args):
209
+ out, ok = await _tools.execute(tool, ctx, args)
210
+ return out, ok, None
211
+
212
+ def audit(self, db, **kw) -> None:
213
+ logger.info("taskwarden_audit %s", kw)
214
+
215
+ def reach_key(self, tool_name, args, region) -> Optional[str]:
216
+ return None
217
+
218
+ def sent_key(self, tool_name, args, region) -> Optional[str]:
219
+ return None
220
+
221
+ async def picture_url(self, image) -> Optional[str]:
222
+ return image if isinstance(image, str) else None
223
+
224
+ async def mark(self, organization_id, thread, agent_id, step) -> None:
225
+ return None
226
+
227
+ # ── Telling people ──
228
+ def post_message(self, db, *, organization_id, thread, sender, body, kind="text",
229
+ task_id=None) -> Any:
230
+ if self.Message is None:
231
+ return None
232
+ msg = self.Message(organization_id=organization_id, thread=thread, sender=sender,
233
+ body=body, kind=kind, task_id=task_id)
234
+ db.add(msg)
235
+ return msg
236
+
237
+ async def finish(self, db, task, body, shown, actor) -> None:
238
+ from .loop import answer_ask
239
+ self.post_message(db, organization_id=task.organization_id, thread=task.thread,
240
+ sender=task.agent_id, body=body,
241
+ kind="result" if task.parent_id else "text", task_id=task.id)
242
+ await answer_ask(self, db, task, body) # a teammate waiting on this answer wakes
243
+
244
+ async def decision_names(self, db, organization_id, decided) -> dict:
245
+ return {}
246
+
247
+ async def owner_fields(self, db, task) -> dict:
248
+ return {}
249
+
250
+ async def notify_parked(self, db, task, pending, name, actor, channel, summary,
251
+ body) -> None:
252
+ return None