taskboy 0.1.1__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.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
taskboy/prompts.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""plain-string prompt templates: the classifier call and the per-task sub-agent session."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from taskboy.models import Task
|
|
7
|
+
|
|
8
|
+
CONVENTIONS_FILENAME = "CONVENTIONS.md" # workspace filename the conventions doc is copied to (runner.py) and cited as (task prompt)
|
|
9
|
+
|
|
10
|
+
CLASSIFICATION_SCHEMA: dict[str, Any] = {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {
|
|
13
|
+
"task_type": {"type": "string", "enum": ["question", "investigation", "bug_fix", "feature", "pr_review", "incident_diagnosis", "jira_ops", "unsupported"]},
|
|
14
|
+
"complexity": {"type": "string", "enum": ["trivial", "standard", "complex", "critical"]},
|
|
15
|
+
"risk": {"type": "string", "enum": ["read_only", "writes_code", "writes_jira", "writes_code_and_jira"]},
|
|
16
|
+
"expected_duration": {"type": "string", "enum": ["minutes", "under_hour", "hours"]},
|
|
17
|
+
"required_integrations": {"type": "array", "items": {"type": "string", "enum": ["github", "aws", "sentry", "jira", "confluence"]}},
|
|
18
|
+
"target_repos": {"type": "array", "items": {"type": "string"}},
|
|
19
|
+
"jira_keys": {"type": "array", "items": {"type": "string"}},
|
|
20
|
+
"effort": {"type": "string", "enum": ["low", "medium", "high", "xhigh", "max", "auto"]},
|
|
21
|
+
},
|
|
22
|
+
"required": ["task_type", "complexity", "risk", "expected_duration", "required_integrations", "target_repos", "jira_keys"],
|
|
23
|
+
"additionalProperties": False,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
QUICK_ANSWER_SCHEMA: dict[str, Any] = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {"action": {"type": "string", "enum": ["answer", "escalate"]}, "answer": {"type": "string"}},
|
|
29
|
+
"required": ["action", "answer"],
|
|
30
|
+
"additionalProperties": False,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# top-level anyOf/oneOf/allOf/if are rejected by the api; quick.py's validate_classification() enforces the
|
|
34
|
+
# classify-branch fields instead
|
|
35
|
+
TRIAGE_SCHEMA: dict[str, Any] = {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"action": {"type": "string", "enum": ["answer", "classify"]},
|
|
39
|
+
"answer": {"type": "string"},
|
|
40
|
+
**CLASSIFICATION_SCHEMA["properties"],
|
|
41
|
+
},
|
|
42
|
+
"required": ["action", "answer"],
|
|
43
|
+
"additionalProperties": False,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def trim_context(context: str | None, max_chars: int = 1500) -> str | None:
|
|
48
|
+
if not context or len(context) <= max_chars:
|
|
49
|
+
return context
|
|
50
|
+
marker = "(earlier messages omitted)\n"
|
|
51
|
+
return marker + context[-(max_chars - len(marker)) :]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classification_guidance(approved_repos: list[str], integrations: list[str], self_repo_line: str = "") -> str:
|
|
55
|
+
"""shared task_type/complexity/jira-key rules used by both classifier_prompt and triage_prompt (issue #41) —
|
|
56
|
+
keep routing rules here so a future change only has to be made in one place."""
|
|
57
|
+
return f"""Approved repositories (only these may appear in target_repos): {json.dumps(approved_repos)}{self_repo_line}
|
|
58
|
+
Available integrations: {json.dumps(integrations)}
|
|
59
|
+
|
|
60
|
+
Engineering questions count as task_type "question" even when there is nothing to execute
|
|
61
|
+
(e.g. "what does HTTP 429 mean?"). Use "unsupported" only for requests with no engineering content at all
|
|
62
|
+
(greetings, chit-chat) or things this system must not do. Closing a pull request and/or deleting its
|
|
63
|
+
(non-protected, agent/-prefixed) branch are supported engineering operations — classify these as "bug_fix", not "unsupported".
|
|
64
|
+
Requests to address, fix, or respond to review comments on a pull request are code changes — classify them
|
|
65
|
+
as "bug_fix", not "pr_review"; "pr_review" is only for actually reviewing a pull request.
|
|
66
|
+
Complexity guide: trivial = single-step lookups; standard = routine engineering; complex = multi-file or
|
|
67
|
+
multi-system work needing sustained reasoning; critical is rare and means a mistake is very costly or the
|
|
68
|
+
reasoning is unusually deep, such as architecture changes, security-sensitive fixes, or data-corruption and
|
|
69
|
+
concurrency bugs.
|
|
70
|
+
Effort sets how much reasoning depth the sub-agent uses: low for trivial lookups, medium or high to match
|
|
71
|
+
routine-to-complex engineering, xhigh or max only for unusually deep reasoning (as rare as "critical"
|
|
72
|
+
complexity). Use "auto" when you have no opinion — the routed profile's own default effort applies instead.
|
|
73
|
+
Jira keys look like PROJ-123; extract them only if actually referenced."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def classifier_prompt(request_text: str, approved_repos: list[str], integrations: list[str], thread_context: str | None = None, self_repo: str | None = None, bot_name: str = "Agent") -> str:
|
|
77
|
+
context = f"\nConversation in the Slack thread where this was requested (context only — classify the request above):\n{thread_context}\n" if thread_context else ""
|
|
78
|
+
self_repo_line = f'\nThe repository "{self_repo}" is {bot_name}\'s own source code (this agent). Requests about {bot_name} itself — "you", "your code", "yourself" — target it.' if self_repo else ""
|
|
79
|
+
return f"""You classify one engineering request for routing. Do not attempt the task.
|
|
80
|
+
|
|
81
|
+
Request:
|
|
82
|
+
{request_text}
|
|
83
|
+
{context}
|
|
84
|
+
|
|
85
|
+
Classify the request.
|
|
86
|
+
|
|
87
|
+
{classification_guidance(approved_repos, integrations, self_repo_line)}"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def quick_answer_prompt(request_text: str, bot_name: str, task_context: str | None, personality: str | None = None) -> str:
|
|
91
|
+
context = task_context or "No task-status context was referenced."
|
|
92
|
+
voice = f"\nPersonality for the answer text:\n{personality}\n" if personality else ""
|
|
93
|
+
return f"""You are {bot_name}'s fast answer path. Return only the requested structured output.
|
|
94
|
+
{voice}
|
|
95
|
+
|
|
96
|
+
Request:
|
|
97
|
+
{request_text}
|
|
98
|
+
|
|
99
|
+
Task-status context, if any:
|
|
100
|
+
{context}
|
|
101
|
+
|
|
102
|
+
Answer only simple general or engineering knowledge, or questions answerable directly from the task-status
|
|
103
|
+
context above. Escalate anything needing repositories, tools, writes, external systems, or investigation.
|
|
104
|
+
For an escalation set answer to an empty string."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def dm_chat_prompt(request_text: str, bot_name: str, history: str | None = None, personality: str | None = None) -> str:
|
|
108
|
+
conversation = history or "No earlier direct-message context is available."
|
|
109
|
+
voice = f"\nPersonality for the answer text:\n{personality}\n" if personality else ""
|
|
110
|
+
return f"""You are {bot_name}, chatting directly with a user in Slack. Return only the requested structured output.
|
|
111
|
+
{voice}
|
|
112
|
+
|
|
113
|
+
Recent direct-message conversation (context only):
|
|
114
|
+
{conversation}
|
|
115
|
+
|
|
116
|
+
Newest user message:
|
|
117
|
+
{request_text}
|
|
118
|
+
|
|
119
|
+
Answer conversationally and directly when the request needs only general knowledge or discussion. Use action
|
|
120
|
+
"escalate" and set answer to an empty string for anything that needs repositories, tools, writes, external
|
|
121
|
+
systems, or investigation. The caller will explain how to start a full task; never claim that you started one."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def triage_prompt(
|
|
125
|
+
request_text: str,
|
|
126
|
+
bot_name: str,
|
|
127
|
+
task_context: str | None,
|
|
128
|
+
approved_repos: list[str],
|
|
129
|
+
integrations: list[str],
|
|
130
|
+
thread_context: str | None = None,
|
|
131
|
+
personality: str | None = None,
|
|
132
|
+
self_repo: str | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
task_status = task_context or "No task-status context was referenced."
|
|
135
|
+
thread = f"\nConversation in the Slack thread (context only):\n{thread_context}\n" if thread_context else ""
|
|
136
|
+
voice = f"\nPersonality for the answer text:\n{personality}\n" if personality else ""
|
|
137
|
+
self_repo_line = f'\nThe repository "{self_repo}" is {bot_name}\'s own source code (this agent). Requests about {bot_name} itself — "you", "your code", "yourself" — target it.' if self_repo else ""
|
|
138
|
+
return f"""You are {bot_name}'s fast triage path. Return only the requested structured output.
|
|
139
|
+
{voice}
|
|
140
|
+
|
|
141
|
+
Request:
|
|
142
|
+
{request_text}
|
|
143
|
+
{thread}
|
|
144
|
+
|
|
145
|
+
Task-status context, if any:
|
|
146
|
+
{task_status}
|
|
147
|
+
|
|
148
|
+
Answer only simple general or engineering knowledge, or questions answerable directly from the task-status
|
|
149
|
+
context above. Greetings and small talk (e.g. "hi", "thanks", "how are you") also get action "answer" with a
|
|
150
|
+
short, friendly conversational reply — never classify them. Anything needing repositories, tools, writes,
|
|
151
|
+
external systems, or investigation must use action "classify" and set answer to an empty string. For action
|
|
152
|
+
"answer", put the complete concise reply in answer; classification fields may be omitted.
|
|
153
|
+
|
|
154
|
+
For action "classify", include every classification field and follow this guidance:
|
|
155
|
+
|
|
156
|
+
{classification_guidance(approved_repos, integrations, self_repo_line)}"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def task_prompt(
|
|
160
|
+
task: Task,
|
|
161
|
+
parent_summary: str | None,
|
|
162
|
+
prior_artifacts: list[dict],
|
|
163
|
+
github: bool = False,
|
|
164
|
+
bot_name: str = "Agent",
|
|
165
|
+
other_bot_name: str = "Reviewer",
|
|
166
|
+
is_reviewer: bool = False,
|
|
167
|
+
thread_context: str | None = None,
|
|
168
|
+
failed_repo_clones: list[str] | None = None,
|
|
169
|
+
cloned_repos: list[str] | None = None,
|
|
170
|
+
skill: dict | None = None,
|
|
171
|
+
conventions: bool = False,
|
|
172
|
+
personality: str | None = None,
|
|
173
|
+
self_repo: str | None = None,
|
|
174
|
+
granted_permissions: dict | None = None,
|
|
175
|
+
jira: bool = False,
|
|
176
|
+
answered_questions: list[dict] | None = None,
|
|
177
|
+
) -> str:
|
|
178
|
+
sections = [
|
|
179
|
+
f"""You are {bot_name}, an autonomous engineering agent (system: taskboy). You are working on task `{task.task_id}`, requested by <@{task.slack_user_id}> in Slack.
|
|
180
|
+
|
|
181
|
+
## Request
|
|
182
|
+
{task.request_text}""",
|
|
183
|
+
]
|
|
184
|
+
if skill:
|
|
185
|
+
args = skill.get("args") or "(none)"
|
|
186
|
+
sections.append(
|
|
187
|
+
f"""## Skill invocation
|
|
188
|
+
This request invokes the /{skill['name']} skill. Follow the skill instructions below exactly. They take precedence over conflicting general guidance, except the safety rules and the Final Report requirement.
|
|
189
|
+
|
|
190
|
+
Arguments: {args}
|
|
191
|
+
|
|
192
|
+
### Skill: /{skill['name']}
|
|
193
|
+
{skill['instructions']}"""
|
|
194
|
+
)
|
|
195
|
+
if parent_summary:
|
|
196
|
+
sections.append(f"## Context from the previous task in this thread\n{parent_summary}")
|
|
197
|
+
if thread_context:
|
|
198
|
+
sections.append(f"## Conversation in this Slack thread\n{thread_context}")
|
|
199
|
+
if answered_questions:
|
|
200
|
+
rounds = "\n\n".join(f"You asked:\n{row['questions']}\n\nThe requester answered:\n{row['answer_text'] or ''}" for row in answered_questions)
|
|
201
|
+
sections.append(
|
|
202
|
+
f"""## Answers from the requester
|
|
203
|
+
You previously asked the requester follow-up questions and they have answered. Work from these answers; do not re-ask them.
|
|
204
|
+
|
|
205
|
+
{rounds}"""
|
|
206
|
+
)
|
|
207
|
+
if prior_artifacts:
|
|
208
|
+
listing = "\n".join(f"- {a['kind']}: {a['external_id']}" + (f" ({a['url']})" if a.get("url") else "") for a in prior_artifacts)
|
|
209
|
+
sections.append(f"## Artifacts that already exist for this task — update them, never recreate them\n{listing}")
|
|
210
|
+
if github:
|
|
211
|
+
other_persona = other_bot_name
|
|
212
|
+
jira_keys = (json.loads(task.classification_json) if task.classification_json else {}).get("jira_keys") or []
|
|
213
|
+
jira_line = f"\n- A Jira issue is referenced ({', '.join(jira_keys)}): include its key in the branch name and pull request title." if jira_keys else ""
|
|
214
|
+
cloned_line = ""
|
|
215
|
+
if cloned_repos:
|
|
216
|
+
paths = ", ".join(f"./{repo.split('/')[-1]}" for repo in cloned_repos)
|
|
217
|
+
cloned_line = f"\n- These repositories are already cloned in your workspace: {paths} — work in them directly; fetch/pull/push work normally (auth handled)."
|
|
218
|
+
failed_clone_line = ""
|
|
219
|
+
if failed_repo_clones:
|
|
220
|
+
names = ", ".join(failed_repo_clones)
|
|
221
|
+
failed_clone_line = f"\n- These target repos could not be pre-cloned (clone them yourself with git; auth is handled): {names}."
|
|
222
|
+
self_repo_line = f"\n- {self_repo} is your own source code — the service you are running as. Changes merged to main deploy automatically. Work only on your `agent/` branch and open a pull request for human review; never attempt to merge it yourself." if self_repo else ""
|
|
223
|
+
no_delegate_line = (
|
|
224
|
+
f"\n- When your job is to change code — including addressing pull request review comments — make the changes yourself. Do not request a {other_persona} review or spawn {other_persona}; {other_persona} reviews pull requests only through the GitHub review-request flow."
|
|
225
|
+
if not is_reviewer
|
|
226
|
+
else ""
|
|
227
|
+
)
|
|
228
|
+
sections.append(
|
|
229
|
+
f"""## GitHub work rules
|
|
230
|
+
- Use git via Bash for clone/commit/push (auth is handled for you); use the mcp__github__* tools for pull request operations.
|
|
231
|
+
- Create branches named `agent/{task.task_id}-<short-slug>`. Pushes may only target `agent/` branches — anything else is blocked at multiple layers and will fail.
|
|
232
|
+
- Docker is available: build and run project images and Makefile targets that use docker compose as needed.
|
|
233
|
+
- Before pushing code changes, run the repository's checks — `make check` (lint, format, mypy, tests) when the Makefile defines it — and fix failures until they pass. Never push code that fails its checks.
|
|
234
|
+
- When you change code to address a pull request review comment, reply to that comment with mcp__github__reply_to_pr_comment as {bot_name}: one or two plain sentences saying what you changed and why it fixes the finding. Only resolve a review thread with mcp__github__resolve_pr_thread that you or {other_persona} started, and only once the code verifiably fixes it.
|
|
235
|
+
- Pull request bodies must contain: Summary, Testing performed, Known limitations. Before opening a PR, check whether one already exists for your branch.
|
|
236
|
+
- Include the pull request link in your `## Reply` when you opened or reviewed one.{no_delegate_line}{jira_line}{cloned_line}{failed_clone_line}{self_repo_line}"""
|
|
237
|
+
)
|
|
238
|
+
if jira:
|
|
239
|
+
sections.append(
|
|
240
|
+
"""## Jira work rules
|
|
241
|
+
- When the request asks you to work on a referenced Jira issue, transition it to `In Progress` with `mcp__jira__transition_issue` before you begin implementation, and include the issue link in your `## Reply`.
|
|
242
|
+
- When you create a Jira issue for engineering work, state the repositories the work will involve directly in the issue description (a `Repositories:` line listing them, or `Repositories: unknown` if it truly can't be determined) so the story clearly names where the work lands.
|
|
243
|
+
- When you create, update, or otherwise alter a Jira issue, include its link in your `## Reply`."""
|
|
244
|
+
)
|
|
245
|
+
if conventions:
|
|
246
|
+
sections.append(
|
|
247
|
+
f"""## Engineering conventions
|
|
248
|
+
Your organization's engineering conventions are in `./{CONVENTIONS_FILENAME}` in your workspace. Before writing, changing, or reviewing any code in the repositories, read that file and follow it — it is ground truth for how this organization's services are structured, coded, tested, and deployed. For read-only questions, consult it whenever conventions are relevant to the answer."""
|
|
249
|
+
)
|
|
250
|
+
if granted_permissions and (granted_permissions.get("tools") or granted_permissions.get("repos")):
|
|
251
|
+
lines = [f"- tool: {name}" for name in granted_permissions.get("tools") or []] + [f"- repo: {name}" for name in granted_permissions.get("repos") or []]
|
|
252
|
+
listing = "\n".join(lines)
|
|
253
|
+
sections.append(
|
|
254
|
+
f"""## Granted permissions
|
|
255
|
+
An operator granted these additional permissions for this task; they are active now, so use them to finish the work you previously requested them for:
|
|
256
|
+
{listing}"""
|
|
257
|
+
)
|
|
258
|
+
if personality:
|
|
259
|
+
sections.append(
|
|
260
|
+
f"""## Personality
|
|
261
|
+
Use this voice only for the requester-facing `## Reply`. Keep the internal Final Report factual and structured. Personality governs voice, not length or structure — the Reply length/format rule below always wins over any brevity guidance here (e.g. "keep replies to a sentence or two").
|
|
262
|
+
|
|
263
|
+
{personality}"""
|
|
264
|
+
)
|
|
265
|
+
report_style = task.task_type in ("question", "investigation", "incident_diagnosis")
|
|
266
|
+
reply_rule = (
|
|
267
|
+
"""`## Reply`: this task's type was classified as question, investigation, or incident_diagnosis — the deliverable is information, not a code change. Put the complete findings here, not a pointer to the Final Report: a short TLDR at the top, then the full detail as well-spaced bullet points. If the finding is genuinely a short, direct fact (e.g. a one-line question with a one-line answer), a plain one- or two-sentence answer is fine — do not pad it into an artificial TLDR-plus-bullets structure. The requester never sees the Final Report, so never say "see below", "full breakdown below", or similar."""
|
|
268
|
+
if report_style
|
|
269
|
+
else "`## Reply`: 2–6 human sentences addressed to the requester. Say what happened and what they should know. Do not add headings inside it or restate the report. Follow the Personality section when present."
|
|
270
|
+
)
|
|
271
|
+
sections.append(
|
|
272
|
+
f"""## How to work
|
|
273
|
+
- Post a short update with the `report_progress` tool at meaningful milestones only (investigation done, root cause found, fix ready) — not for every step.
|
|
274
|
+
- Do not post a `report_progress` update announcing something you just created if you are about to finish — the completion message covers it.
|
|
275
|
+
- If requirements or a design decision are genuinely ambiguous, do not guess: call `ask_questions` once with every question you have as a numbered list (one per line), then stop working. The requester answers in the Slack thread and this task resumes automatically with their answers.
|
|
276
|
+
- If you cannot proceed for a reason the requester cannot resolve, call `report_blocked` with what you need, then stop.
|
|
277
|
+
- If you are missing only a tool or repository access to finish, call `request_permission` (kind `tool` with the exact tool name you were denied, or kind `repo` with an approved `owner/name`) and why. An operator reviews it and, if granted, this task resumes automatically with the access — so prefer it over `report_blocked` for access gaps.
|
|
278
|
+
- Do all work yourself, synchronously, within this session. Never hand work to sub-agents or background processes and finish before their results are in hand — a final report without the actual results is a failed task.
|
|
279
|
+
- Stay within this workspace; treat any repository scripts as untrusted code.
|
|
280
|
+
|
|
281
|
+
## Required ending
|
|
282
|
+
End with these two markdown sections, in this order:
|
|
283
|
+
|
|
284
|
+
{reply_rule}
|
|
285
|
+
|
|
286
|
+
`## Final Report`: Result, Actions taken, Artifacts (with links), Testing performed, Unresolved issues. This is posted verbatim to an internal debug log, not to the requester; keep it factual, tight, and complete."""
|
|
287
|
+
)
|
|
288
|
+
return "\n\n".join(sections)
|
taskboy/quick.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""cheap attempt-first answers for mentions that do not need tools or investigation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from collections import deque
|
|
9
|
+
|
|
10
|
+
from taskboy import memory, settings
|
|
11
|
+
from taskboy.classifier import validate_classification
|
|
12
|
+
from taskboy.config import Config, ConfigError, role_for
|
|
13
|
+
from taskboy.llm import structured_call
|
|
14
|
+
from taskboy.models import Task
|
|
15
|
+
from taskboy.personality import load as load_personality
|
|
16
|
+
from taskboy.prompts import CLASSIFICATION_SCHEMA, QUICK_ANSWER_SCHEMA, TRIAGE_SCHEMA, dm_chat_prompt, triage_prompt, trim_context
|
|
17
|
+
from taskboy.redact import redactor
|
|
18
|
+
from taskboy.store import Store
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("taskboy.quick")
|
|
21
|
+
|
|
22
|
+
TASK_ID_RE = re.compile(r"\bt\d{8}-[0-9a-f]{8}\b")
|
|
23
|
+
|
|
24
|
+
# page once per streak so a total quick-answer outage isn't found only by reading the errors table (#97)
|
|
25
|
+
QUICK_PAGE_AFTER_CONSECUTIVE_FAILURES = 5
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class QuickAnswer:
|
|
29
|
+
def __init__(self, store: Store, config: Config, debug=None):
|
|
30
|
+
self.store = store
|
|
31
|
+
self.config = config
|
|
32
|
+
self.debug = debug
|
|
33
|
+
section = config.raw.get("quick_answer") or {}
|
|
34
|
+
tier = section.get("tier", "haiku")
|
|
35
|
+
models = config.raw.get("models") or {}
|
|
36
|
+
if tier not in models:
|
|
37
|
+
raise ConfigError(f"quick_answer.tier {tier!r} is not in the model catalog")
|
|
38
|
+
self.model_alias = str(tier)
|
|
39
|
+
self.model_id = str(models[tier]["id"])
|
|
40
|
+
self.timeout_seconds = float(section.get("timeout_seconds", 20))
|
|
41
|
+
self.max_per_user_per_hour = int(section.get("max_per_user_per_hour", 30))
|
|
42
|
+
self._attempts: dict[str, deque[float]] = {}
|
|
43
|
+
self._consecutive_failures = {"triage": 0, "dm": 0}
|
|
44
|
+
|
|
45
|
+
async def try_answer(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
channel_id: str,
|
|
49
|
+
thread_ts: str,
|
|
50
|
+
user_id: str,
|
|
51
|
+
text: str,
|
|
52
|
+
parent: Task | None,
|
|
53
|
+
team_id: str,
|
|
54
|
+
message_ts: str,
|
|
55
|
+
thread_context: str | None = None,
|
|
56
|
+
debug_thread_ts: str | None = None,
|
|
57
|
+
debug_permalink: str | None = None,
|
|
58
|
+
) -> tuple[Task | None, dict | None]:
|
|
59
|
+
"""return either a recorded answer task or a reusable classification."""
|
|
60
|
+
started = time.monotonic()
|
|
61
|
+
try:
|
|
62
|
+
if not self._allow_attempt(user_id, started):
|
|
63
|
+
logger.info("quick answer rate cap reached for %s", user_id)
|
|
64
|
+
return None, None
|
|
65
|
+
context = self._task_context(text, parent)
|
|
66
|
+
personality = load_personality(self.config.personality_path)
|
|
67
|
+
github = (self.config.raw.get("github") or {}) if self.config.service_enabled("github") else {}
|
|
68
|
+
approved_repos = github.get("approved_repos") or []
|
|
69
|
+
role = role_for(self.config.roles, user_id)
|
|
70
|
+
if role is not None and role.repos is not None:
|
|
71
|
+
approved_repos = [repo for repo in approved_repos if repo in role.repos]
|
|
72
|
+
self_repo = str(github.get("self_repo") or "")
|
|
73
|
+
prompt = triage_prompt(
|
|
74
|
+
redactor.redact(text)[:6000],
|
|
75
|
+
self.config.agent_name,
|
|
76
|
+
context,
|
|
77
|
+
approved_repos,
|
|
78
|
+
self.config.enabled_integrations(),
|
|
79
|
+
trim_context(thread_context),
|
|
80
|
+
personality[0] if personality else None,
|
|
81
|
+
self_repo if self_repo in approved_repos else None,
|
|
82
|
+
)
|
|
83
|
+
async with asyncio.timeout(self.timeout_seconds):
|
|
84
|
+
result, usage = await self._call_model(prompt)
|
|
85
|
+
if result.get("action") == "classify":
|
|
86
|
+
validated = validate_classification(result)
|
|
87
|
+
classification = {key: validated[key] for key in CLASSIFICATION_SCHEMA["required"]}
|
|
88
|
+
if usage:
|
|
89
|
+
classification["_triage_usage"] = usage
|
|
90
|
+
self._consecutive_failures["triage"] = 0
|
|
91
|
+
return None, classification
|
|
92
|
+
if result.get("action") != "answer" or not str(result.get("answer") or "").strip():
|
|
93
|
+
self._consecutive_failures["triage"] = 0
|
|
94
|
+
return None, None
|
|
95
|
+
answer = str(result["answer"]).strip()
|
|
96
|
+
latency = time.monotonic() - started
|
|
97
|
+
task = self.store.record_quick_answer(
|
|
98
|
+
slack_team_id=team_id,
|
|
99
|
+
slack_channel_id=channel_id,
|
|
100
|
+
slack_thread_ts=thread_ts,
|
|
101
|
+
slack_message_ts=message_ts,
|
|
102
|
+
slack_user_id=user_id,
|
|
103
|
+
request_text=text,
|
|
104
|
+
answer_text=answer,
|
|
105
|
+
model_alias=self.model_alias,
|
|
106
|
+
model_id=self.model_id,
|
|
107
|
+
parent_task_id=parent.task_id if parent else None,
|
|
108
|
+
latency_s=latency,
|
|
109
|
+
debug_thread_ts=debug_thread_ts,
|
|
110
|
+
debug_permalink=debug_permalink,
|
|
111
|
+
)
|
|
112
|
+
if personality:
|
|
113
|
+
self.store.add_event(task.task_id, "personality", {"hash": personality[1], "path": self.config.personality_path})
|
|
114
|
+
if usage:
|
|
115
|
+
self.store.add_usage(task.task_id, "quick_answer", self.model_id, **usage)
|
|
116
|
+
logger.info("quick answer completed for %s in %.3fs", task.task_id, latency)
|
|
117
|
+
self._consecutive_failures["triage"] = 0
|
|
118
|
+
return task, None
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning("quick answer escalated after failure: %s", e)
|
|
121
|
+
self.store.add_error("quick_answer", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"user_id": user_id, "channel_id": channel_id})
|
|
122
|
+
await self._on_model_failure("triage", str(e))
|
|
123
|
+
return None, None
|
|
124
|
+
|
|
125
|
+
async def chat(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
channel_id: str,
|
|
129
|
+
user_id: str,
|
|
130
|
+
text: str,
|
|
131
|
+
team_id: str,
|
|
132
|
+
message_ts: str,
|
|
133
|
+
history: str | None = None,
|
|
134
|
+
) -> tuple[Task | None, str]:
|
|
135
|
+
"""Answer one DM without ever creating a queued task."""
|
|
136
|
+
started = time.monotonic()
|
|
137
|
+
if not self._allow_attempt(user_id, started):
|
|
138
|
+
logger.info("quick chat rate cap reached for %s", user_id)
|
|
139
|
+
return None, "rate_limited"
|
|
140
|
+
try:
|
|
141
|
+
personality = load_personality(self.config.personality_path)
|
|
142
|
+
prompt = dm_chat_prompt(
|
|
143
|
+
redactor.redact(text)[:6000],
|
|
144
|
+
self.config.agent_name,
|
|
145
|
+
trim_context(history, 6000),
|
|
146
|
+
personality[0] if personality else None,
|
|
147
|
+
)
|
|
148
|
+
async with asyncio.timeout(self.timeout_seconds):
|
|
149
|
+
result, usage = await self._call_chat_model(prompt)
|
|
150
|
+
if result.get("action") != "answer" or not str(result.get("answer") or "").strip():
|
|
151
|
+
self._consecutive_failures["dm"] = 0
|
|
152
|
+
return None, "escalate"
|
|
153
|
+
answer = str(result["answer"]).strip()
|
|
154
|
+
task = self.store.record_quick_answer(
|
|
155
|
+
slack_team_id=team_id,
|
|
156
|
+
slack_channel_id=channel_id,
|
|
157
|
+
slack_thread_ts=message_ts,
|
|
158
|
+
slack_message_ts=message_ts,
|
|
159
|
+
slack_user_id=user_id,
|
|
160
|
+
request_text=text,
|
|
161
|
+
answer_text=answer,
|
|
162
|
+
model_alias=self.model_alias,
|
|
163
|
+
model_id=self.model_id,
|
|
164
|
+
latency_s=time.monotonic() - started,
|
|
165
|
+
)
|
|
166
|
+
if personality:
|
|
167
|
+
self.store.add_event(task.task_id, "personality", {"hash": personality[1], "path": self.config.personality_path})
|
|
168
|
+
if usage:
|
|
169
|
+
self.store.add_usage(task.task_id, "quick_answer", self.model_id, **usage)
|
|
170
|
+
self._consecutive_failures["dm"] = 0
|
|
171
|
+
return task, "answer"
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.warning("quick chat escalated after failure: %s", e)
|
|
174
|
+
self.store.add_error("quick_answer", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"user_id": user_id, "channel_id": channel_id, "mode": "dm"})
|
|
175
|
+
await self._on_model_failure("dm", str(e))
|
|
176
|
+
return None, "escalate"
|
|
177
|
+
|
|
178
|
+
async def _on_model_failure(self, path: str, message: str) -> None:
|
|
179
|
+
"""page the debug channel once per streak, per path, once failures reach the threshold."""
|
|
180
|
+
self._consecutive_failures[path] += 1
|
|
181
|
+
count = self._consecutive_failures[path]
|
|
182
|
+
if count == QUICK_PAGE_AFTER_CONSECUTIVE_FAILURES and self.debug is not None:
|
|
183
|
+
await self.debug.system_error("quick_answer", f"quick answer {path} path has failed {count} times in a row: {message}")
|
|
184
|
+
|
|
185
|
+
def _allow_attempt(self, user_id: str, now: float) -> bool:
|
|
186
|
+
cutoff = now - 3600
|
|
187
|
+
for existing_user, existing_attempts in list(self._attempts.items()):
|
|
188
|
+
while existing_attempts and existing_attempts[0] <= cutoff:
|
|
189
|
+
existing_attempts.popleft()
|
|
190
|
+
if not existing_attempts:
|
|
191
|
+
del self._attempts[existing_user]
|
|
192
|
+
attempts = self._attempts.setdefault(user_id, deque())
|
|
193
|
+
if len(attempts) >= self.max_per_user_per_hour:
|
|
194
|
+
return False
|
|
195
|
+
attempts.append(now)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
def _task_context(self, text: str, parent: Task | None) -> str | None:
|
|
199
|
+
blocks: list[str] = []
|
|
200
|
+
seen: set[str] = set()
|
|
201
|
+
if parent is not None:
|
|
202
|
+
blocks.append(_task_block(parent, memory.read_summary(settings.MEMORY_ROOT, parent.task_id)))
|
|
203
|
+
seen.add(parent.task_id)
|
|
204
|
+
for task_id in TASK_ID_RE.findall(text)[:3]:
|
|
205
|
+
if task_id in seen:
|
|
206
|
+
continue
|
|
207
|
+
task = self.store.get_task(task_id)
|
|
208
|
+
if task is not None:
|
|
209
|
+
blocks.append(_task_block(task, memory.read_summary(settings.MEMORY_ROOT, task.task_id)))
|
|
210
|
+
seen.add(task_id)
|
|
211
|
+
return "\n\n".join(blocks) or None
|
|
212
|
+
|
|
213
|
+
async def _call_model(self, prompt: str) -> tuple[dict, dict | None]:
|
|
214
|
+
"""the per-call API seam, kept conversation-free and patched in tests."""
|
|
215
|
+
return await structured_call(self.model_id, prompt, TRIAGE_SCHEMA)
|
|
216
|
+
|
|
217
|
+
async def _call_chat_model(self, prompt: str) -> tuple[dict, dict | None]:
|
|
218
|
+
return await structured_call(self.model_id, prompt, QUICK_ANSWER_SCHEMA)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _task_block(task: Task, summary: str | None) -> str:
|
|
222
|
+
detail = task.result_summary or task.error or task.blocked_reason or ""
|
|
223
|
+
lines = [
|
|
224
|
+
f"task {task.task_id}",
|
|
225
|
+
f"state: {task.state}",
|
|
226
|
+
f"model: {task.model_alias or 'n/a'}",
|
|
227
|
+
f"created: {task.created_at}",
|
|
228
|
+
f"finished: {task.finished_at or 'n/a'}",
|
|
229
|
+
]
|
|
230
|
+
if detail:
|
|
231
|
+
lines.append(f"result/error: {detail[:500]}")
|
|
232
|
+
if summary:
|
|
233
|
+
lines.append(f"memory summary: {summary[:500]}")
|
|
234
|
+
return redactor.redact("\n".join(lines))[:1500]
|
taskboy/redact.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""secret redaction, applied at the durable/outbound sinks: task-row text fields, audit events, slack posts, memory files (§8.2, MEM-011).
|
|
2
|
+
|
|
3
|
+
two mechanisms: pattern rules for known token families, and an exact-match registry of live
|
|
4
|
+
secret values — every loaded or minted credential is registered so it can never survive any sink.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
# (pattern, replacement) — replacement keeps any labeled prefix group
|
|
10
|
+
PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
11
|
+
(re.compile(r"ghp_[A-Za-z0-9]{20,}"), "[redacted]"), # github classic pat
|
|
12
|
+
(re.compile(r"ghs_[A-Za-z0-9]{20,}"), "[redacted]"), # github app installation token
|
|
13
|
+
(re.compile(r"gho_[A-Za-z0-9]{20,}"), "[redacted]"), # github oauth token
|
|
14
|
+
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[redacted]"), # github fine-grained pat
|
|
15
|
+
(re.compile(r"xox[baprse]-[A-Za-z0-9-]{10,}"), "[redacted]"), # slack bot/user tokens
|
|
16
|
+
(re.compile(r"xapp-[A-Za-z0-9-]{10,}"), "[redacted]"), # slack app-level token
|
|
17
|
+
(re.compile(r"\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[A-Z0-9]{16}\b"), "[redacted]"), # aws access key ids
|
|
18
|
+
(re.compile(r"sk-ant-[A-Za-z0-9_-]{10,}"), "[redacted]"), # anthropic api keys
|
|
19
|
+
(re.compile(r"sntrys_[A-Za-z0-9_+/=.-]{10,}"), "[redacted]"), # sentry org tokens
|
|
20
|
+
(re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+"), "[redacted]"), # jwts
|
|
21
|
+
(re.compile(r"(?i)(authorization:\s*(?:bearer|basic)\s+)\S+"), r"\1[redacted]"),
|
|
22
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"), "[redacted private key]"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Redactor:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self._values: set[str] = set()
|
|
29
|
+
|
|
30
|
+
def register(self, value: str | None) -> None:
|
|
31
|
+
"""exact-match redaction for a live secret value (loaded from the bundle or minted by the broker)."""
|
|
32
|
+
if value and len(value) >= 8: # refuse tiny values that would shred normal text
|
|
33
|
+
self._values.add(value)
|
|
34
|
+
|
|
35
|
+
def unregister(self, value: str | None) -> None:
|
|
36
|
+
self._values.discard(value or "")
|
|
37
|
+
|
|
38
|
+
def redact(self, text: str) -> str:
|
|
39
|
+
if not text:
|
|
40
|
+
return text
|
|
41
|
+
for value in self._values:
|
|
42
|
+
text = text.replace(value, "[redacted]")
|
|
43
|
+
for pattern, replacement in PATTERNS:
|
|
44
|
+
text = pattern.sub(replacement, text)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# process-wide instance: secrets.load_secrets() registers values, sinks call redactor.redact()
|
|
49
|
+
redactor = Redactor()
|