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.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- 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
|
+
|