briar-cli 1.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.
Files changed (179) hide show
  1. briar/__init__.py +8 -0
  2. briar/__main__.py +11 -0
  3. briar/_registry.py +36 -0
  4. briar/agent/__init__.py +16 -0
  5. briar/agent/_llm.py +90 -0
  6. briar/agent/_llms/__init__.py +40 -0
  7. briar/agent/_llms/anthropic_llm.py +185 -0
  8. briar/agent/_llms/bedrock.py +154 -0
  9. briar/agent/_llms/gemini.py +152 -0
  10. briar/agent/_llms/openai_llm.py +129 -0
  11. briar/agent/runner.py +364 -0
  12. briar/agent/tools.py +355 -0
  13. briar/auth/__init__.py +42 -0
  14. briar/auth/_acquirer.py +121 -0
  15. briar/auth/_acquirers/__init__.py +62 -0
  16. briar/auth/_acquirers/aws_sso.py +185 -0
  17. briar/auth/_acquirers/aws_static.py +54 -0
  18. briar/auth/_acquirers/bitbucket.py +60 -0
  19. briar/auth/_acquirers/github_device.py +129 -0
  20. briar/auth/_acquirers/github_pat.py +44 -0
  21. briar/auth/_acquirers/infisical.py +80 -0
  22. briar/auth/_acquirers/jira_session.py +102 -0
  23. briar/auth/_acquirers/jira_token.py +61 -0
  24. briar/auth/_acquirers/linear.py +46 -0
  25. briar/auth/_prompt.py +155 -0
  26. briar/cli.py +149 -0
  27. briar/commands/__init__.py +48 -0
  28. briar/commands/agent.py +692 -0
  29. briar/commands/auth.py +281 -0
  30. briar/commands/base.py +46 -0
  31. briar/commands/context.py +143 -0
  32. briar/commands/dashboard.py +61 -0
  33. briar/commands/extract.py +79 -0
  34. briar/commands/iac.py +44 -0
  35. briar/commands/runbook.py +110 -0
  36. briar/commands/secrets.py +165 -0
  37. briar/commands/version.py +17 -0
  38. briar/credentials/__init__.py +48 -0
  39. briar/credentials/_bootstrap.py +93 -0
  40. briar/credentials/_bootstraps/__init__.py +80 -0
  41. briar/credentials/_bootstraps/infisical.py +115 -0
  42. briar/credentials/_store.py +63 -0
  43. briar/credentials/aws_secrets.py +102 -0
  44. briar/credentials/envfile.py +171 -0
  45. briar/credentials/infisical.py +183 -0
  46. briar/credentials/ssm.py +83 -0
  47. briar/credentials/vault.py +109 -0
  48. briar/dashboard/__init__.py +13 -0
  49. briar/dashboard/collectors.py +1354 -0
  50. briar/dashboard/server.py +154 -0
  51. briar/dashboard/templates/index.html +678 -0
  52. briar/decorators.py +48 -0
  53. briar/env_vars.py +92 -0
  54. briar/error_policy.py +273 -0
  55. briar/errors.py +48 -0
  56. briar/extract/__init__.py +52 -0
  57. briar/extract/_cloud.py +157 -0
  58. briar/extract/_clouds/__init__.py +39 -0
  59. briar/extract/_clouds/aws.py +190 -0
  60. briar/extract/_clouds/azure.py +152 -0
  61. briar/extract/_clouds/gcp.py +135 -0
  62. briar/extract/_gh.py +159 -0
  63. briar/extract/_provider.py +218 -0
  64. briar/extract/_providers/__init__.py +60 -0
  65. briar/extract/_providers/bitbucket.py +276 -0
  66. briar/extract/_providers/github.py +277 -0
  67. briar/extract/_tracker.py +124 -0
  68. briar/extract/_trackers/__init__.py +44 -0
  69. briar/extract/_trackers/_jira_auth.py +258 -0
  70. briar/extract/_trackers/bitbucket.py +131 -0
  71. briar/extract/_trackers/github_issues.py +139 -0
  72. briar/extract/_trackers/jira.py +191 -0
  73. briar/extract/_trackers/linear.py +172 -0
  74. briar/extract/_user_filter.py +150 -0
  75. briar/extract/active_tickets.py +71 -0
  76. briar/extract/active_work.py +87 -0
  77. briar/extract/aws_infra.py +79 -0
  78. briar/extract/aws_services/__init__.py +31 -0
  79. briar/extract/aws_services/base.py +25 -0
  80. briar/extract/aws_services/ecs.py +43 -0
  81. briar/extract/aws_services/lambda_.py +38 -0
  82. briar/extract/aws_services/logs.py +39 -0
  83. briar/extract/aws_services/rds.py +35 -0
  84. briar/extract/aws_services/sqs.py +25 -0
  85. briar/extract/base.py +290 -0
  86. briar/extract/code_hotspots.py +134 -0
  87. briar/extract/codebase_conventions.py +72 -0
  88. briar/extract/composer.py +85 -0
  89. briar/extract/github_deployments.py +106 -0
  90. briar/extract/language_detectors/__init__.py +24 -0
  91. briar/extract/language_detectors/base.py +29 -0
  92. briar/extract/language_detectors/go.py +26 -0
  93. briar/extract/language_detectors/node.py +42 -0
  94. briar/extract/language_detectors/python.py +41 -0
  95. briar/extract/pr_archaeology.py +131 -0
  96. briar/extract/pr_review_context.py +123 -0
  97. briar/extract/reviewer_profile.py +141 -0
  98. briar/extract/ticket_archaeology.py +115 -0
  99. briar/extract/ticket_context.py +95 -0
  100. briar/formatting/__init__.py +67 -0
  101. briar/formatting/base.py +25 -0
  102. briar/formatting/csv.py +34 -0
  103. briar/formatting/json.py +19 -0
  104. briar/formatting/quiet.py +29 -0
  105. briar/formatting/table.py +97 -0
  106. briar/formatting/yaml.py +35 -0
  107. briar/iac/__init__.py +18 -0
  108. briar/iac/config_file.py +114 -0
  109. briar/iac/models.py +232 -0
  110. briar/iac/reference_map.py +33 -0
  111. briar/iac/runbook/__init__.py +32 -0
  112. briar/iac/runbook/executor.py +365 -0
  113. briar/iac/runbook/models.py +156 -0
  114. briar/iac/runbook/scheduler.py +187 -0
  115. briar/iac/scaffold/__init__.py +25 -0
  116. briar/iac/scaffold/_composer.py +308 -0
  117. briar/iac/scaffold/_knowledge.py +119 -0
  118. briar/iac/scaffold/archetypes/__init__.py +26 -0
  119. briar/iac/scaffold/archetypes/base.py +85 -0
  120. briar/iac/scaffold/archetypes/engineer.py +64 -0
  121. briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
  122. briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
  123. briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
  124. briar/iac/scaffold/archetypes/triager.py +50 -0
  125. briar/iac/scaffold/base.py +19 -0
  126. briar/iac/scaffold/implementation.py +59 -0
  127. briar/iac/scaffold/pr_fixes.py +52 -0
  128. briar/iac/scaffold/rules/__init__.py +64 -0
  129. briar/iac/scaffold/rules/base.py +121 -0
  130. briar/iac/scaffold/rules/commit_as_human.md +22 -0
  131. briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
  132. briar/iac/scaffold/rules/no_force_push.md +19 -0
  133. briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
  134. briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
  135. briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
  136. briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
  137. briar/iac/scaffold/shapes/__init__.py +38 -0
  138. briar/iac/scaffold/shapes/base.py +17 -0
  139. briar/iac/scaffold/shapes/one_shot.py +48 -0
  140. briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
  141. briar/iac/scaffold/shapes/triage.py +49 -0
  142. briar/iac/scaffold/sources/__init__.py +26 -0
  143. briar/iac/scaffold/sources/aws.py +69 -0
  144. briar/iac/scaffold/sources/base.py +61 -0
  145. briar/iac/scaffold/sources/bitbucket.py +164 -0
  146. briar/iac/scaffold/sources/github.py +155 -0
  147. briar/iac/scaffold/sources/jira.py +147 -0
  148. briar/iac/scaffold/triggers/__init__.py +23 -0
  149. briar/iac/scaffold/triggers/base.py +24 -0
  150. briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
  151. briar/iac/scaffold/triggers/github_webhook.py +52 -0
  152. briar/iac/scaffold/triggers/manual.py +16 -0
  153. briar/iac/scaffold/triggers/schedule_cron.py +37 -0
  154. briar/log_context.py +73 -0
  155. briar/logging.py +68 -0
  156. briar/messaging/__init__.py +66 -0
  157. briar/messaging/_writer.py +90 -0
  158. briar/messaging/bitbucket_pr_comment.py +93 -0
  159. briar/messaging/github_pr_comment.py +90 -0
  160. briar/messaging/jira_comment.py +63 -0
  161. briar/messaging/jira_transition.py +71 -0
  162. briar/messaging/slack_channel.py +73 -0
  163. briar/messaging/telegram_chat.py +68 -0
  164. briar/notify/__init__.py +44 -0
  165. briar/notify/_sink.py +27 -0
  166. briar/notify/email.py +59 -0
  167. briar/notify/pagerduty.py +67 -0
  168. briar/notify/slack.py +49 -0
  169. briar/notify/telegram.py +48 -0
  170. briar/pagination.py +37 -0
  171. briar/settings.py +3 -0
  172. briar/storage/__init__.py +73 -0
  173. briar/storage/base.py +137 -0
  174. briar/storage/file.py +111 -0
  175. briar/storage/postgres.py +353 -0
  176. briar_cli-1.1.1.dist-info/METADATA +1031 -0
  177. briar_cli-1.1.1.dist-info/RECORD +179 -0
  178. briar_cli-1.1.1.dist-info/WHEEL +4 -0
  179. briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,152 @@
1
+ """Google Gemini `LLMProvider`.
2
+
3
+ Lazy-imports ``google.generativeai``; opt-in via ``pip install
4
+ briar-cli[gemini]``. Gemini's tool-call shape lives inside
5
+ ``response.candidates[0].content.parts[].function_call`` (no separate
6
+ "tool_calls" array)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import logging
12
+ import os
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from briar.agent._llm import LLMProvider, LLMResponse, LLMToolCall
16
+
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def _import_genai() -> Optional[Any]:
22
+ try:
23
+ return importlib.import_module("google.generativeai")
24
+ except ImportError:
25
+ return None
26
+
27
+
28
+ class GeminiLLM(LLMProvider):
29
+ kind = "gemini"
30
+ DEFAULT_MODEL = "gemini-2.5-pro"
31
+
32
+ def __init__(self, *, model: str = "") -> None:
33
+ self._model_name = model or self.DEFAULT_MODEL
34
+ self._api_key = os.environ.get("GEMINI_API_KEY", "")
35
+ self._model = None
36
+
37
+ def is_available(self) -> bool:
38
+ return bool(self._api_key) and _import_genai() is not None
39
+
40
+ def _build_model(self, system: str, tools: List[Dict[str, Any]]):
41
+ genai = _import_genai()
42
+ if genai is None:
43
+ raise RuntimeError("google-generativeai not installed — run `pip install briar-cli[gemini]`")
44
+ if not self._api_key:
45
+ raise RuntimeError("GEMINI_API_KEY env var is required for GeminiLLM")
46
+ genai.configure(api_key=self._api_key)
47
+ # Gemini's `tools` argument accepts a list of dicts in OpenAPI shape;
48
+ # genai converts internally.
49
+ gemini_tools = self._to_gemini_tools(tools) if tools else None
50
+ return genai.GenerativeModel(
51
+ model_name=self._model_name,
52
+ system_instruction=system or None,
53
+ tools=gemini_tools,
54
+ )
55
+
56
+ def complete(
57
+ self,
58
+ *,
59
+ system: str,
60
+ messages: List[Dict[str, Any]],
61
+ tools: List[Dict[str, Any]],
62
+ max_tokens: int,
63
+ ) -> LLMResponse:
64
+ model = self._build_model(system, tools)
65
+ contents = self._to_gemini_contents(messages)
66
+ resp = model.generate_content(
67
+ contents=contents,
68
+ generation_config={"max_output_tokens": max_tokens},
69
+ )
70
+ return self._normalise(resp)
71
+
72
+ def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
73
+ # Gemini correlates tool results by *function name*, not by id —
74
+ # callers pass the function name as `tool_call_id`.
75
+ content = {"function_response": {"name": tool_call_id, "response": {"content": output, "is_error": is_error}}}
76
+ return {"role": "user", "parts": [content]}
77
+
78
+ # ---- shape translation ------------------------------------------------
79
+
80
+ @staticmethod
81
+ def _to_gemini_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
82
+ # Gemini expects a list of `Tool` objects; each contains
83
+ # `function_declarations` with `{name, description, parameters}`.
84
+ return [
85
+ {
86
+ "function_declarations": [
87
+ {
88
+ "name": t.get("name", ""),
89
+ "description": t.get("description", ""),
90
+ "parameters": t.get("input_schema", {}),
91
+ }
92
+ for t in tools
93
+ ]
94
+ }
95
+ ]
96
+
97
+ @staticmethod
98
+ def _to_gemini_contents(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
99
+ """Anthropic uses role ``user``/``assistant``; Gemini uses
100
+ ``user``/``model``. Plain-text content gets wrapped in parts."""
101
+ out: List[Dict[str, Any]] = []
102
+ for m in messages:
103
+ role = m.get("role", "user")
104
+ if role == "assistant":
105
+ role = "model"
106
+ content = m.get("content")
107
+ if isinstance(content, str):
108
+ out.append({"role": role, "parts": [{"text": content}]})
109
+ else:
110
+ out.append({"role": role, "parts": content or []})
111
+ return out
112
+
113
+ @staticmethod
114
+ def _normalise(resp: Any) -> LLMResponse:
115
+ candidates = getattr(resp, "candidates", None) or []
116
+ if not candidates:
117
+ return LLMResponse(text="", tool_calls=[], stop_reason="", input_tokens=0, output_tokens=0)
118
+ candidate = candidates[0]
119
+ parts = getattr(candidate.content, "parts", None) or []
120
+ text_parts: List[str] = []
121
+ tool_calls: List[LLMToolCall] = []
122
+ for part in parts:
123
+ text = getattr(part, "text", None)
124
+ if text:
125
+ text_parts.append(text)
126
+ fc = getattr(part, "function_call", None)
127
+ if fc is not None:
128
+ tool_calls.append(
129
+ LLMToolCall(
130
+ id=fc.name, # Gemini has no native id — use name
131
+ name=fc.name,
132
+ arguments=dict(fc.args or {}),
133
+ )
134
+ )
135
+ # Gemini `finish_reason`: STOP / MAX_TOKENS / SAFETY / RECITATION / OTHER
136
+ finish = getattr(candidate, "finish_reason", None)
137
+ finish_name = getattr(finish, "name", str(finish or ""))
138
+ if tool_calls:
139
+ stop = "tool_use"
140
+ elif finish_name == "STOP":
141
+ stop = "end_turn"
142
+ else:
143
+ stop = finish_name.lower()
144
+ usage = getattr(resp, "usage_metadata", None)
145
+ return LLMResponse(
146
+ text="".join(text_parts),
147
+ tool_calls=tool_calls,
148
+ stop_reason=stop,
149
+ input_tokens=int(getattr(usage, "prompt_token_count", 0) or 0),
150
+ output_tokens=int(getattr(usage, "candidates_token_count", 0) or 0),
151
+ raw_assistant_message={"role": "model", "parts": [getattr(p, "to_dict", lambda: {})() for p in parts]},
152
+ )
@@ -0,0 +1,129 @@
1
+ """OpenAI `LLMProvider`.
2
+
3
+ Lazy-imports ``openai`` so the package is an opt-in extra
4
+ (``pip install briar-cli[openai]``). ``is_available()`` reports False
5
+ when the SDK isn't installed; ``complete()`` raises a clear message
6
+ pointing at the right install command."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import json
12
+ import logging
13
+ import os
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from briar.agent._llm import LLMProvider, LLMResponse, LLMToolCall
17
+
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def _import_openai() -> Optional[Any]:
23
+ try:
24
+ return importlib.import_module("openai")
25
+ except ImportError:
26
+ return None
27
+
28
+
29
+ class OpenAILLM(LLMProvider):
30
+ kind = "openai"
31
+ DEFAULT_MODEL = "gpt-4o"
32
+
33
+ def __init__(self, *, model: str = "") -> None:
34
+ self._model = model or self.DEFAULT_MODEL
35
+ self._api_key = os.environ.get("OPENAI_API_KEY", "")
36
+ self._client = None
37
+
38
+ def is_available(self) -> bool:
39
+ return bool(self._api_key) and _import_openai() is not None
40
+
41
+ def _build_client(self):
42
+ if self._client is not None:
43
+ return self._client
44
+ openai = _import_openai()
45
+ if openai is None:
46
+ raise RuntimeError("openai package not installed — run `pip install briar-cli[openai]`")
47
+ if not self._api_key:
48
+ raise RuntimeError("OPENAI_API_KEY env var is required for OpenAILLM")
49
+ self._client = openai.OpenAI(api_key=self._api_key)
50
+ return self._client
51
+
52
+ def complete(
53
+ self,
54
+ *,
55
+ system: str,
56
+ messages: List[Dict[str, Any]],
57
+ tools: List[Dict[str, Any]],
58
+ max_tokens: int,
59
+ ) -> LLMResponse:
60
+ client = self._build_client()
61
+ api_messages: List[Dict[str, Any]] = []
62
+ if system:
63
+ api_messages.append({"role": "system", "content": system})
64
+ api_messages.extend(messages)
65
+
66
+ kwargs: Dict[str, Any] = {
67
+ "model": self._model,
68
+ "messages": api_messages,
69
+ "max_tokens": max_tokens,
70
+ }
71
+ if tools:
72
+ kwargs["tools"] = self._to_openai_tools(tools)
73
+
74
+ resp = client.chat.completions.create(**kwargs)
75
+ return self._normalise(resp)
76
+
77
+ def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
78
+ # OpenAI's echo-back shape: a top-level "tool" message with the call id.
79
+ # `is_error` is signalled via content prefix since the API has no native flag.
80
+ content = f"[ERROR] {output}" if is_error else output
81
+ return {"role": "tool", "tool_call_id": tool_call_id, "content": content}
82
+
83
+ @staticmethod
84
+ def _to_openai_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
85
+ out: List[Dict[str, Any]] = []
86
+ for t in tools:
87
+ out.append(
88
+ {
89
+ "type": "function",
90
+ "function": {
91
+ "name": t.get("name", ""),
92
+ "description": t.get("description", ""),
93
+ "parameters": t.get("input_schema", {}),
94
+ },
95
+ }
96
+ )
97
+ return out
98
+
99
+ @staticmethod
100
+ def _normalise(resp: Any) -> LLMResponse:
101
+ choice = resp.choices[0]
102
+ message = choice.message
103
+ text = message.content or ""
104
+ tool_calls: List[LLMToolCall] = []
105
+ for tc in (message.tool_calls or []) if hasattr(message, "tool_calls") else []:
106
+ # OpenAI delivers `arguments` as a JSON string — parse it for the caller.
107
+ raw_args = getattr(tc.function, "arguments", "") or "{}"
108
+ try:
109
+ args = json.loads(raw_args)
110
+ except (ValueError, TypeError):
111
+ args = {}
112
+ tool_calls.append(LLMToolCall(id=tc.id, name=tc.function.name, arguments=args))
113
+ # Map OpenAI's `finish_reason` onto the abstraction's vocabulary.
114
+ finish_reason = choice.finish_reason or ""
115
+ if finish_reason == "stop":
116
+ stop = "end_turn"
117
+ elif finish_reason == "tool_calls":
118
+ stop = "tool_use"
119
+ else:
120
+ stop = finish_reason
121
+ usage = getattr(resp, "usage", None)
122
+ return LLMResponse(
123
+ text=text,
124
+ tool_calls=tool_calls,
125
+ stop_reason=stop,
126
+ input_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
127
+ output_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
128
+ raw_assistant_message=message.model_dump() if hasattr(message, "model_dump") else {"role": "assistant", "content": text},
129
+ )
briar/agent/runner.py ADDED
@@ -0,0 +1,364 @@
1
+ """Agent runner — LLM-provider-driven tool-use loop.
2
+
3
+ Loads the archetype's system prompt, splices in the company's
4
+ knowledge, and drives an `LLMProvider.complete` loop until the model
5
+ returns `end_turn` (or we hit guardrails). Tool calls dispatch to the
6
+ `BashTool` / `ReadFileTool` / `WriteFileTool` / `EditFileTool` primitives
7
+ in `briar.agent.tools`.
8
+
9
+ Provider-agnostic: selecting Anthropic / OpenAI / Gemini / Bedrock is a
10
+ constructor arg. The runner reads normalised `LLMResponse` shapes so
11
+ this file doesn't grow per-vendor branches.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import textwrap
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List
21
+
22
+ from briar.agent._llm import LLMProvider, LLMToolCall
23
+ from briar.agent._llms import make_llm
24
+ from briar.agent.tools import BashTool, EditFileTool, ReadFileTool, SendMessageTool, ToolError, WriteFileTool
25
+ from briar.iac.scaffold.archetypes import ARCHETYPES
26
+ from briar.log_context import log_context
27
+
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class AgentRunResult:
34
+ """Outcome of one agent run — what happened, what to report."""
35
+
36
+ company: str
37
+ task: str
38
+ iterations: int = 0
39
+ stop_reason: str = ""
40
+ final_text: str = ""
41
+ tool_calls: int = 0
42
+ input_tokens: int = 0
43
+ output_tokens: int = 0
44
+ commits: List[str] = field(default_factory=list)
45
+ error: str = ""
46
+
47
+ def cost_summary(self) -> str:
48
+ # Sonnet 4.5 list pricing as of 2026-05: $3/M input, $15/M output.
49
+ cost = (self.input_tokens / 1_000_000) * 3.0 + (self.output_tokens / 1_000_000) * 15.0
50
+ return f"in={self.input_tokens:,} out={self.output_tokens:,} ≈${cost:.3f}"
51
+
52
+
53
+ class AgentRunner:
54
+ """One-shot agent execution against a single (company, task) target.
55
+
56
+ Loads the archetype's persona + the spliced knowledge for the company,
57
+ builds the system prompt, then drives the Anthropic API tool-use loop
58
+ until the model is done or we hit a guardrail. All tool side effects
59
+ are confined to the worktree the caller hands us.
60
+ """
61
+
62
+ DEFAULT_MAX_ITERATIONS = 30
63
+ DEFAULT_MAX_TOKENS_PER_TURN = 8_000
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ company: str,
69
+ task: str,
70
+ archetype_name: str,
71
+ workdir: Path,
72
+ knowledge_store: Any,
73
+ target: str,
74
+ oauth_token: str = "", # kept for back-compat; ignored — Anthropic adapter reads env directly
75
+ model: str = "",
76
+ max_iterations: int = 0,
77
+ extra_user_instructions: str = "",
78
+ llm_kind: str = "anthropic",
79
+ llm: LLMProvider = None, # type: ignore[assignment] — tests inject; runtime falls through
80
+ task_context_sections: List[Any] = None, # type: ignore[assignment]
81
+ dry_run: bool = False,
82
+ messages: Dict[str, Any] = None, # type: ignore[assignment] — runbook messages: block
83
+ ) -> None:
84
+ self._company = company
85
+ self._task = task
86
+ self._workdir = workdir.resolve()
87
+ self._archetype = ARCHETYPES[archetype_name]
88
+ self._store = knowledge_store
89
+ self._target = target
90
+ self._llm: LLMProvider = llm or make_llm(llm_kind, model=model)
91
+ self._max_iterations = max_iterations or self.DEFAULT_MAX_ITERATIONS
92
+ self._extra = extra_user_instructions
93
+ # JIT context fetched by the caller (FetchTicketContext /
94
+ # FetchPrReviewContext output sections, etc.). Spliced into the
95
+ # system prompt below the archetype's persona — sits next to the
96
+ # scheduled knowledge sections so the agent treats them the same.
97
+ self._task_context_sections = list(task_context_sections or [])
98
+ self._dry_run = dry_run
99
+ # Tools share the same root list so the agent can read/write
100
+ # inside the worktree but nowhere else.
101
+ roots = [self._workdir]
102
+ self._bash = BashTool(base_cwd=self._workdir)
103
+ self._read = ReadFileTool(allowed_roots=roots)
104
+ self._write = WriteFileTool(allowed_roots=roots)
105
+ self._edit = EditFileTool(allowed_roots=roots)
106
+ # Only bind the SendMessageTool when the runbook actually has
107
+ # message channels configured for this company. Empty messages
108
+ # dict → tool not registered → LLM falls back to bash.
109
+ self._send = SendMessageTool(messages=messages or {}, company=company) if messages else None
110
+
111
+ def run(self) -> AgentRunResult:
112
+ with log_context(company=self._company, task=self._task, agent=self._archetype.name):
113
+ # Dry-run path: build the same prompts the LLM would see,
114
+ # print them, and return. Don't gate on LLM creds (the
115
+ # whole point is to validate the prompt rendering without
116
+ # an LLM call) — but DO gate on JIT extractor / archetype
117
+ # construction having succeeded, which happens at __init__.
118
+ if self._dry_run:
119
+ return self._dry_run_report()
120
+ if not self._llm.is_available():
121
+ return AgentRunResult(
122
+ company=self._company,
123
+ task=self._task,
124
+ error=f"LLM ({self._llm.kind}) credentials missing — see env_vars.py for the required vars",
125
+ )
126
+ system = self._build_system_prompt()
127
+ initial_user = self._build_initial_user_message()
128
+ log.info(
129
+ "agent-start: archetype=%s llm=%s max_iter=%d workdir=%s",
130
+ self._archetype.name,
131
+ self._llm.kind,
132
+ self._max_iterations,
133
+ self._workdir,
134
+ )
135
+ log.debug("agent-system-prompt-bytes=%d initial-user-bytes=%d", len(system), len(initial_user))
136
+ messages: List[Dict[str, Any]] = [{"role": "user", "content": initial_user}]
137
+ result = AgentRunResult(company=self._company, task=self._task)
138
+ for iteration in range(1, self._max_iterations + 1):
139
+ result.iterations = iteration
140
+ try:
141
+ response = self._llm.complete(
142
+ system=system,
143
+ messages=messages,
144
+ tools=self._tool_specs(),
145
+ max_tokens=self.DEFAULT_MAX_TOKENS_PER_TURN,
146
+ )
147
+ except Exception: # noqa: BLE001
148
+ log.exception("agent-failed: LLM raised on iteration %d", iteration)
149
+ result.error = "api: LLM call failed (see traceback in log)"
150
+ return result
151
+
152
+ result.input_tokens += response.input_tokens
153
+ result.output_tokens += response.output_tokens
154
+ stop = response.stop_reason
155
+ result.stop_reason = stop
156
+ log.info(
157
+ "agent-turn iter=%d stop=%s tool_calls=%d in=%d out=%d",
158
+ iteration,
159
+ stop,
160
+ len(response.tool_calls),
161
+ response.input_tokens,
162
+ response.output_tokens,
163
+ )
164
+ if stop == "end_turn":
165
+ result.final_text = response.text
166
+ log.info("agent-done iter=%d %s", iteration, result.cost_summary())
167
+ return result
168
+ if stop != "tool_use":
169
+ result.error = f"unexpected stop_reason={stop}"
170
+ log.warning("agent-stopped: %s", result.error)
171
+ return result
172
+ tool_results = self._execute_all_tool_uses(response.tool_calls, result)
173
+ messages.append(response.raw_assistant_message)
174
+ messages.append({"role": "user", "content": tool_results})
175
+ result.error = f"hit iteration ceiling ({self._max_iterations})"
176
+ log.warning("agent-ceiling: %d iterations exhausted %s", self._max_iterations, result.cost_summary())
177
+ return result
178
+
179
+ def _build_system_prompt(self) -> str:
180
+ persona = self._archetype.build_persona(self._target)
181
+ from briar.iac.scaffold._knowledge import KnowledgeSplicer
182
+
183
+ try:
184
+ splicer = KnowledgeSplicer(self._store, self._company)
185
+ prologue = splicer.prologue(self._archetype)
186
+ except Exception: # noqa: BLE001
187
+ log.exception("agent-system: KnowledgeSplicer failed — continuing without prologue")
188
+ prologue = ""
189
+
190
+ body = textwrap.dedent(
191
+ f"""\
192
+ You are: {persona['role']}.
193
+
194
+ Goal: {persona['goal']}
195
+
196
+ {persona['backstory']}
197
+
198
+ ---
199
+ Working directory: {self._workdir}
200
+ All file operations (read_file, write_file, edit_file) and shell
201
+ commands (bash) must operate inside this directory. The
202
+ scheduler set the human git identity already; verify with
203
+ `git config user.name` before your first commit.
204
+ """
205
+ )
206
+ sections: List[str] = []
207
+ if prologue:
208
+ sections.append(prologue)
209
+ # Task-scoped sections (ticket-context, pr-review-context, …)
210
+ # render with their own `## <title>` heading + body, mirroring
211
+ # the format the scheduled KnowledgeSplicer emits. The agent
212
+ # treats both layers as one continuous context block.
213
+ for section in self._task_context_sections:
214
+ if getattr(section, "is_empty", False):
215
+ continue
216
+ sections.append(f"## {section.title}\n\n{section.body}")
217
+ if not sections:
218
+ return body
219
+ return body + "\n\n" + "\n\n".join(sections)
220
+
221
+ def _build_initial_user_message(self) -> str:
222
+ intro = textwrap.dedent(
223
+ f"""\
224
+ Run the {self._archetype.name} workflow for company {self._company!r}
225
+ in repo {self._target!r}. The working directory at {self._workdir}
226
+ is a clean git worktree on the PR branch you should fix.
227
+
228
+ Procedure (follow exactly):
229
+ 1. `bash`: `cd <workdir> && gh pr view <N> --json reviewDecision,headRefName,statusCheckRollup`
230
+ 2. Skip the PR if reviewDecision=APPROVED AND every required check is green AND every
231
+ open review thread has only positive comments. Report 'skipped' and end.
232
+ 3. Otherwise, list every open inline review-thread comment + every PR-level issue comment.
233
+ 4. For each thread requesting a code change: apply the smallest correct fix via edit_file,
234
+ then commit (one commit per thread; subject ≤72 chars; body cites the comment id).
235
+ 5. After all fixes, push fast-forward to the PR's branch.
236
+ 6. Reply to each thread you addressed via `gh api .../comments/{{id}}/replies` with
237
+ one sentence citing the commit SHA.
238
+ 7. Report a short summary of commits made + threads replied to. Then stop.
239
+
240
+ Strict constraints:
241
+ - NEVER --force, --amend, rebase, squash, or filter-branch.
242
+ - NEVER commit as a bot identity. Run `git config user.name` first to verify it's a human.
243
+ - NEVER touch files outside the diff already under review unless the fix needs it.
244
+ - If a thread is subjective ('did you consider X?'), reply with clarification, no commit.
245
+ - If you cannot resolve a thread safely, leave a comment explaining why and skip it.
246
+ """
247
+ )
248
+ if self._extra:
249
+ intro = intro + "\n\nAdditional instructions:\n" + self._extra
250
+ return intro
251
+
252
+ def _tool_specs(self) -> List[Dict[str, Any]]:
253
+ specs: List[Dict[str, Any]] = [
254
+ {"name": self._bash.name, "description": self._bash.description, "input_schema": self._bash.INPUT_SCHEMA},
255
+ {"name": self._read.name, "description": self._read.description, "input_schema": self._read.INPUT_SCHEMA},
256
+ {"name": self._write.name, "description": self._write.description, "input_schema": self._write.INPUT_SCHEMA},
257
+ {"name": self._edit.name, "description": self._edit.description, "input_schema": self._edit.INPUT_SCHEMA},
258
+ ]
259
+ if self._send is not None:
260
+ # Append the channel list to the description so the LLM sees
261
+ # the actual handles at agent-start time.
262
+ channels = self._send.channels()
263
+ description = self._send.description + (f"\n\nAvailable channels: {', '.join(channels)}" if channels else "")
264
+ specs.append({"name": self._send.name, "description": description, "input_schema": self._send.INPUT_SCHEMA})
265
+ return specs
266
+
267
+ def _dry_run_report(self) -> AgentRunResult:
268
+ """Build the same prompts the LLM would see, print them to
269
+ stdout, and return without a single API call.
270
+
271
+ Goal: let the operator validate the JIT context wiring
272
+ (ticket-context / pr-review-context renders correctly, the
273
+ archetype's consumes order is what they expect, the tool
274
+ specs look right) WITHOUT spending tokens. Skips both LLM
275
+ availability checks and the iteration loop entirely."""
276
+ system = self._build_system_prompt()
277
+ initial_user = self._build_initial_user_message()
278
+ tools = self._tool_specs()
279
+
280
+ sep = "=" * 78
281
+ print(sep)
282
+ print(f"DRY RUN — archetype={self._archetype.name} task={self._task} target={self._target}")
283
+ print(f"company={self._company} llm={self._llm.kind} (LLM call SKIPPED)")
284
+ print(sep)
285
+ print()
286
+ print("─── SYSTEM PROMPT ───────────────────────────────────────────────────────")
287
+ print(system)
288
+ print()
289
+ print("─── INITIAL USER MESSAGE ────────────────────────────────────────────────")
290
+ print(initial_user)
291
+ print()
292
+ print("─── TOOLS BOUND ─────────────────────────────────────────────────────────")
293
+ for t in tools:
294
+ print(f" - {t['name']}: {t['description']}")
295
+ print()
296
+ print("─── TASK-SCOPED SECTIONS (count + titles) ───────────────────────────────")
297
+ if self._task_context_sections:
298
+ for section in self._task_context_sections:
299
+ title = getattr(section, "title", "(no title)")
300
+ body_bytes = len(getattr(section, "body", "") or "")
301
+ print(f" - {title} ({body_bytes} bytes)")
302
+ else:
303
+ print(" (none — pass --ticket-key / --pr to populate)")
304
+ print()
305
+ print(sep)
306
+ print(f"system_prompt_bytes={len(system)} initial_user_bytes={len(initial_user)} tool_count={len(tools)}")
307
+ print(sep)
308
+
309
+ return AgentRunResult(
310
+ company=self._company,
311
+ task=self._task,
312
+ iterations=0,
313
+ stop_reason="dry_run",
314
+ final_text="(dry run — no LLM call)",
315
+ )
316
+
317
+ def _execute_all_tool_uses(self, tool_calls: List[LLMToolCall], result: AgentRunResult) -> List[Dict[str, Any]]:
318
+ """Dispatch each tool call from the LLM, then format the result
319
+ in the provider's echo-back shape via `LLMProvider.format_tool_result`.
320
+ Keeps the runner free of any vendor-specific result shape."""
321
+ tool_results: List[Dict[str, Any]] = []
322
+ for call in tool_calls:
323
+ result.tool_calls += 1
324
+ outcome = self._dispatch_tool(call.name, call.arguments, result)
325
+ tool_results.append(
326
+ self._llm.format_tool_result(
327
+ tool_call_id=call.id,
328
+ output=outcome["content"],
329
+ is_error=outcome["is_error"],
330
+ )
331
+ )
332
+ return tool_results
333
+
334
+ def _dispatch_tool(self, name: str, raw_input: Any, result: AgentRunResult) -> Dict[str, Any]:
335
+ try:
336
+ if name == self._bash.name:
337
+ out = self._bash.run(**raw_input)
338
+ self._record_commit_if_any(out, result)
339
+ return {"content": out, "is_error": False}
340
+ if name == self._read.name:
341
+ return {"content": self._read.run(**raw_input), "is_error": False}
342
+ if name == self._write.name:
343
+ return {"content": self._write.run(**raw_input), "is_error": False}
344
+ if name == self._edit.name:
345
+ return {"content": self._edit.run(**raw_input), "is_error": False}
346
+ if self._send is not None and name == self._send.name:
347
+ return {"content": self._send.run(**raw_input), "is_error": False}
348
+ return {"content": f"unknown tool {name!r}", "is_error": True}
349
+ except ToolError as exc:
350
+ log.warning("tool %s error: %s", name, exc)
351
+ return {"content": str(exc), "is_error": True}
352
+ except Exception as exc: # noqa: BLE001
353
+ log.exception("tool %s raised unexpectedly", name)
354
+ return {"content": f"unexpected {type(exc).__name__}: {exc}", "is_error": True}
355
+
356
+ @staticmethod
357
+ def _record_commit_if_any(bash_output: str, result: AgentRunResult) -> None:
358
+ # `git commit` prints `[branch sha] subject` on the first line of stdout.
359
+ import re
360
+
361
+ match = re.search(r"\[[^\]]+\s+([a-f0-9]{7,40})\]", bash_output)
362
+ if match:
363
+ result.commits.append(match.group(1))
364
+