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
briar/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """briar — terminal client for the Briar agent-orchestration API.
2
+
3
+ Stdlib-only. Importing the top-level package exposes only the version;
4
+ public surfaces live in `briar.cli` (entry point) and the typed
5
+ sub-packages (`briar.commands`, `briar.iac`, `briar.formatting`, etc.).
6
+ """
7
+
8
+ __version__ = "1.1.0"
briar/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Allow `python -m briar` to drive the CLI without an installed entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from briar.cli import main
8
+
9
+
10
+ if __name__ == "__main__":
11
+ sys.exit(main())
briar/_registry.py ADDED
@@ -0,0 +1,36 @@
1
+ """Defensive `build_registry` helper.
2
+
3
+ Every plugin family in the codebase builds its `_REGISTRY` dict with
4
+ the same pattern: ``{x.name: x for x in (Item1(), Item2(), ...)}``.
5
+ That comprehension silently drops a duplicate-name collision (Python
6
+ dict-literal semantics: later assignment wins).
7
+
8
+ If two adapters ever claim the same `name` — say, two refactors both
9
+ typing ``kind = "github"`` — the registry would silently expose only
10
+ one. Hours of debugging.
11
+
12
+ This module's `build_registry()` does the exact same thing but raises
13
+ on a dup. Every registry in the codebase should use it."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Iterable, TypeVar
18
+
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ def build_registry(items: Iterable[T], *, kind: str, name_attr: str = "name") -> dict:
24
+ """Build the `{item.<name_attr>: item}` map, raising on a
25
+ duplicate. `kind` is the human-readable family name shown in the
26
+ error message ("repository provider", "tracker", "agent op", etc.)."""
27
+ out: dict = {}
28
+ for item in items:
29
+ key = getattr(item, name_attr, None)
30
+ if not key:
31
+ raise RuntimeError(f"build_registry({kind}): item {item!r} has empty {name_attr!r}")
32
+ if key in out:
33
+ existing = out[key]
34
+ raise RuntimeError(f"build_registry({kind}): duplicate {name_attr}={key!r} (already registered: {existing!r}, second: {item!r})")
35
+ out[key] = item
36
+ return out
@@ -0,0 +1,16 @@
1
+ """Autonomous agent runtime.
2
+
3
+ `briar agent prfix --company X [--pr N]` invokes an Anthropic-API-driven
4
+ loop that reads PR review threads, applies minimum-correct fixes, and
5
+ commits + pushes follow-ups as the human GitHub identity. The system
6
+ prompt comes from the pr-fixer archetype + the spliced knowledge for
7
+ the company; the tools are git/gh/file-edit primitives behind a strict
8
+ allowlist.
9
+
10
+ The runner is deliberately separate from the scheduler — opt-in via the
11
+ CLI for now. Wiring it into the scheduled prfix task is a follow-up.
12
+ """
13
+
14
+ from briar.agent.runner import AgentRunner, AgentRunResult
15
+
16
+ __all__ = ["AgentRunner", "AgentRunResult"]
briar/agent/_llm.py ADDED
@@ -0,0 +1,90 @@
1
+ """`LLMProvider` — vendor-neutral facade the agent runner uses instead
2
+ of constructing an Anthropic / OpenAI / Bedrock client directly.
3
+
4
+ Strategy + Registry, same shape as `RepositoryProvider` and
5
+ `TrackerProvider`. Concrete adapters live in `_llms/`.
6
+
7
+ The trick with LLM abstraction is that each vendor's tool-call format
8
+ (Anthropic `tool_use` blocks vs OpenAI `function_call` vs Bedrock's
9
+ shape) differs in both the model's *output* and the format you echo
10
+ *results* back. So this contract has two verbs: `complete` (one turn)
11
+ and `format_tool_result` (how to wire a tool's output into the next
12
+ turn's messages). The runner iterates; the provider stays stateless."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, ClassVar, Dict, List
20
+
21
+ from briar.error_policy import ErrorPolicyRegistry
22
+
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LLMToolCall:
29
+ """One tool-use request from the model."""
30
+
31
+ id: str
32
+ name: str
33
+ arguments: Dict[str, Any]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class LLMResponse:
38
+ """One turn's response, normalised across vendors.
39
+
40
+ `raw_assistant_message` carries the provider-specific echo-back
41
+ payload so the runner can append it to `messages` for the next
42
+ turn without knowing the vendor's format. This is the only field
43
+ that isn't fully normalised — every other field is portable."""
44
+
45
+ text: str
46
+ tool_calls: List[LLMToolCall]
47
+ stop_reason: str
48
+ input_tokens: int
49
+ output_tokens: int
50
+ raw_assistant_message: Dict[str, Any] = field(default_factory=dict)
51
+
52
+
53
+ class LLMProvider(ABC):
54
+ """Strategy contract. Adapters translate one vendor's API onto
55
+ these two verbs."""
56
+
57
+ kind: ClassVar[str] = ""
58
+
59
+ @classmethod
60
+ def default_error_policies(cls) -> ErrorPolicyRegistry:
61
+ """The provider's default error-response policies — consumed
62
+ by the agent runner's RetryingExecutor. Subclasses override to
63
+ encode their SDK's exception taxonomy (rate limits, transient
64
+ errors, 5xx, etc.). Base returns an empty registry — the
65
+ executor will Abort on every exception, preserving the original
66
+ "propagate everything" behaviour for providers that haven't yet
67
+ declared their own policies."""
68
+ return ErrorPolicyRegistry()
69
+
70
+ @abstractmethod
71
+ def is_available(self) -> bool:
72
+ """True iff credentials are present and the provider is usable."""
73
+
74
+ @abstractmethod
75
+ def complete(
76
+ self,
77
+ *,
78
+ system: str,
79
+ messages: List[Dict[str, Any]],
80
+ tools: List[Dict[str, Any]],
81
+ max_tokens: int,
82
+ ) -> LLMResponse:
83
+ """One turn. Returns the normalised response. The provider
84
+ handles retries / rate-limit / auth internally."""
85
+
86
+ @abstractmethod
87
+ def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
88
+ """Provider-specific shape for echoing one tool's output back
89
+ to the model. Anthropic: ``{"type": "tool_result", ...}``;
90
+ OpenAI: ``{"role": "tool", "tool_call_id": ..., "content": ...}``."""
@@ -0,0 +1,40 @@
1
+ """LLM provider registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Tuple, Type
6
+
7
+ from briar._registry import build_registry
8
+ from briar.agent._llm import LLMProvider
9
+ from briar.agent._llms.anthropic_llm import AnthropicLLM
10
+ from briar.agent._llms.bedrock import BedrockLLM
11
+ from briar.agent._llms.gemini import GeminiLLM
12
+ from briar.agent._llms.openai_llm import OpenAILLM
13
+ from briar.errors import CliError
14
+
15
+
16
+ LLMS: Dict[str, Type[LLMProvider]] = build_registry(
17
+ (AnthropicLLM, OpenAILLM, GeminiLLM, BedrockLLM),
18
+ kind="LLM provider",
19
+ name_attr="kind",
20
+ )
21
+
22
+
23
+ class LLMRegistry:
24
+ @classmethod
25
+ def kinds(cls) -> Tuple[str, ...]:
26
+ return tuple(LLMS.keys())
27
+
28
+ @classmethod
29
+ def make(cls, kind: str, *, model: str = "") -> LLMProvider:
30
+ llm_cls = LLMS.get(kind)
31
+ if llm_cls is None:
32
+ known = ", ".join(sorted(LLMS.keys()))
33
+ raise CliError(f"unknown LLM provider {kind!r}; known: {known}")
34
+ return llm_cls(model=model)
35
+
36
+
37
+ make_llm = LLMRegistry.make
38
+
39
+
40
+ __all__ = ["LLMS", "LLMProvider", "LLMRegistry", "make_llm"]
@@ -0,0 +1,185 @@
1
+ """Anthropic `LLMProvider`. Captures the call shape that
2
+ `agent/runner.py` previously inlined: OAuth `auth_token` + the
3
+ `oauth-2025-04-20` beta header, the rate-limit retry schedule
4
+ (now expressed declaratively via the error-policy registry), and
5
+ the `tool_use` block normalisation."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from typing import Any, Dict, List
12
+
13
+ from briar.agent._llm import LLMProvider, LLMResponse, LLMToolCall
14
+ from briar.error_policy import (
15
+ Abort,
16
+ ErrorPolicyRegistry,
17
+ ExceptionTypePolicy,
18
+ HttpStatusPolicy,
19
+ RetryAfter,
20
+ RetryingExecutor,
21
+ )
22
+
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ class AnthropicLLM(LLMProvider):
28
+ kind = "anthropic"
29
+ DEFAULT_MODEL = "claude-sonnet-4-5"
30
+
31
+ @classmethod
32
+ def default_error_policies(cls) -> ErrorPolicyRegistry:
33
+ """Anthropic's SDK exception taxonomy → retry/abort strategy.
34
+
35
+ Order is intentional — more-specific matches come first. The
36
+ ``HttpStatusPolicy(APIStatusError, status=…)`` entries fire
37
+ before the broad ``ExceptionTypePolicy(APIStatusError, …)``
38
+ fallback, so specific status codes get tailored waits.
39
+
40
+ Tunable: edit the ``wait_seconds`` values below or compose an
41
+ overlay via ``registry.with_(extra_policy)`` from a company's
42
+ YAML if/when that lands."""
43
+ import anthropic
44
+
45
+ return ErrorPolicyRegistry(
46
+ policies=(
47
+ # 429 — account-level rate limit. Anthropic resets on
48
+ # the hour; wait 1h then retry rather than the old
49
+ # 30s-doubling schedule, which never recovers when the
50
+ # quota itself is exhausted.
51
+ ExceptionTypePolicy(
52
+ exception_type=anthropic.RateLimitError,
53
+ decision=RetryAfter(wait_seconds=3600, reason="anthropic rate limit"),
54
+ ),
55
+ # Transient TCP-level / DNS / TLS errors.
56
+ ExceptionTypePolicy(
57
+ exception_type=anthropic.APIConnectionError,
58
+ decision=RetryAfter(wait_seconds=10, reason="anthropic transient connect"),
59
+ ),
60
+ # Targeted 503 (service-unavailable) — short retry.
61
+ HttpStatusPolicy(
62
+ exception_type=anthropic.APIStatusError,
63
+ status=503,
64
+ decision=RetryAfter(wait_seconds=30, reason="anthropic 503 service unavailable"),
65
+ ),
66
+ # 529 — Anthropic's "overloaded" signal. Longer wait.
67
+ HttpStatusPolicy(
68
+ exception_type=anthropic.APIStatusError,
69
+ status=529,
70
+ decision=RetryAfter(wait_seconds=120, reason="anthropic 529 overloaded"),
71
+ ),
72
+ # 401 / 403 — auth misconfig. No amount of retrying
73
+ # will fix it; abort fast so the operator sees it.
74
+ HttpStatusPolicy(
75
+ exception_type=anthropic.APIStatusError,
76
+ status=401,
77
+ decision=Abort(reason="anthropic 401 — check CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY"),
78
+ ),
79
+ HttpStatusPolicy(
80
+ exception_type=anthropic.APIStatusError,
81
+ status=403,
82
+ decision=Abort(reason="anthropic 403 — forbidden (model access / billing)"),
83
+ ),
84
+ ),
85
+ )
86
+
87
+ def __init__(self, *, model: str = "") -> None:
88
+ self._model = model or self.DEFAULT_MODEL
89
+ self._oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "")
90
+ self._api_key = os.environ.get("ANTHROPIC_API_KEY", "")
91
+ self._client = None
92
+ # Build the executor once per provider instance. The registry
93
+ # is immutable; reusing the same executor across .complete()
94
+ # calls preserves the agent's retry budget across one task.
95
+ self._executor = RetryingExecutor(
96
+ registry=self.default_error_policies(),
97
+ max_attempts=5,
98
+ )
99
+
100
+ def _build_client(self):
101
+ if self._client is not None:
102
+ return self._client
103
+ import anthropic
104
+
105
+ if self._oauth_token:
106
+ # Subscription billing via OAuth; the beta header is required.
107
+ log.info("anthropic-auth: using CLAUDE_CODE_OAUTH_TOKEN")
108
+ self._client = anthropic.Anthropic(
109
+ auth_token=self._oauth_token,
110
+ default_headers={"anthropic-beta": "oauth-2025-04-20"},
111
+ )
112
+ elif self._api_key:
113
+ log.info("anthropic-auth: using ANTHROPIC_API_KEY")
114
+ self._client = anthropic.Anthropic(api_key=self._api_key)
115
+ else:
116
+ raise RuntimeError("Anthropic: CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY required")
117
+ return self._client
118
+
119
+ def is_available(self) -> bool:
120
+ return bool(self._oauth_token or self._api_key)
121
+
122
+ def complete(
123
+ self,
124
+ *,
125
+ system: str,
126
+ messages: List[Dict[str, Any]],
127
+ tools: List[Dict[str, Any]],
128
+ max_tokens: int,
129
+ ) -> LLMResponse:
130
+ """One turn. The executor handles every retryable failure mode
131
+ declared in ``default_error_policies()`` — this method has zero
132
+ ``try / except`` of its own. Adding a new error class is a
133
+ registry entry, not a code change here."""
134
+ client = self._build_client()
135
+
136
+ def _call() -> LLMResponse:
137
+ response = client.messages.create(
138
+ model=self._model,
139
+ max_tokens=max_tokens,
140
+ system=system,
141
+ tools=tools,
142
+ messages=messages,
143
+ )
144
+ return self._normalise(response)
145
+
146
+ return self._executor.run(_call)
147
+
148
+ def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
149
+ result: Dict[str, Any] = {
150
+ "type": "tool_result",
151
+ "tool_use_id": tool_call_id,
152
+ "content": output,
153
+ }
154
+ if is_error:
155
+ result["is_error"] = True
156
+ return result
157
+
158
+ @staticmethod
159
+ def _normalise(response) -> LLMResponse:
160
+ """Translate Anthropic's typed response into LLMResponse."""
161
+ tool_calls: List[LLMToolCall] = []
162
+ text_parts: List[str] = []
163
+ for block in response.content:
164
+ block_type = getattr(block, "type", "")
165
+ if block_type == "text":
166
+ text_parts.append(getattr(block, "text", ""))
167
+ elif block_type == "tool_use":
168
+ tool_calls.append(
169
+ LLMToolCall(
170
+ id=getattr(block, "id", ""),
171
+ name=getattr(block, "name", ""),
172
+ arguments=dict(getattr(block, "input", {}) or {}),
173
+ )
174
+ )
175
+ return LLMResponse(
176
+ text="".join(text_parts),
177
+ tool_calls=tool_calls,
178
+ stop_reason=str(getattr(response, "stop_reason", "") or ""),
179
+ input_tokens=int((getattr(response, "usage", None) and response.usage.input_tokens) or 0),
180
+ output_tokens=int((getattr(response, "usage", None) and response.usage.output_tokens) or 0),
181
+ raw_assistant_message={
182
+ "role": "assistant",
183
+ "content": [b.model_dump() for b in response.content],
184
+ },
185
+ )
@@ -0,0 +1,154 @@
1
+ """AWS Bedrock `LLMProvider`.
2
+
3
+ Uses the unified Bedrock ``Converse`` API which abstracts over the
4
+ provider-specific protocols (Anthropic, Mistral, Meta, Cohere all
5
+ expose the same `converse(modelId, system, messages, toolConfig)`
6
+ surface).
7
+
8
+ Auth: ambient AWS credential chain (boto3 default). Region comes from
9
+ ``AWS_REGION`` or the boto3 default chain.
10
+
11
+ Model IDs are vendor-prefixed (e.g.
12
+ ``anthropic.claude-sonnet-4-20250514-v1:0``,
13
+ ``meta.llama3-70b-instruct-v1:0``). The Converse API normalises the
14
+ response shape across all of them — this adapter doesn't branch on
15
+ the prefix."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any, Dict, List
21
+
22
+ from briar.agent._llm import LLMProvider, LLMResponse, LLMToolCall
23
+
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ class BedrockLLM(LLMProvider):
29
+ kind = "bedrock"
30
+ DEFAULT_MODEL = "anthropic.claude-sonnet-4-20250514-v1:0"
31
+
32
+ def __init__(self, *, model: str = "") -> None:
33
+ self._model = model or self.DEFAULT_MODEL
34
+ self._client = None
35
+
36
+ def _make_client(self):
37
+ if self._client is not None:
38
+ return self._client
39
+ import boto3
40
+
41
+ self._client = boto3.client("bedrock-runtime")
42
+ return self._client
43
+
44
+ def is_available(self) -> bool:
45
+ try:
46
+ import boto3 # noqa: F401
47
+
48
+ return True
49
+ except ImportError:
50
+ return False
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._make_client()
61
+ # Translate Anthropic-shaped `tools` (name/description/input_schema)
62
+ # onto Bedrock's toolConfig.tools[].toolSpec format.
63
+ tool_config = self._to_bedrock_tools(tools)
64
+ kwargs: Dict[str, Any] = {
65
+ "modelId": self._model,
66
+ "system": [{"text": system}] if system else [],
67
+ "messages": self._to_bedrock_messages(messages),
68
+ "inferenceConfig": {"maxTokens": max_tokens},
69
+ }
70
+ if tool_config:
71
+ kwargs["toolConfig"] = tool_config
72
+
73
+ resp = client.converse(**kwargs)
74
+ return self._normalise(resp)
75
+
76
+ def format_tool_result(self, tool_call_id: str, output: str, is_error: bool = False) -> Dict[str, Any]:
77
+ result: Dict[str, Any] = {
78
+ "toolResult": {
79
+ "toolUseId": tool_call_id,
80
+ "content": [{"text": output}],
81
+ }
82
+ }
83
+ if is_error:
84
+ result["toolResult"]["status"] = "error"
85
+ return {"role": "user", "content": [result]}
86
+
87
+ # ---- shape translation ------------------------------------------------
88
+
89
+ @staticmethod
90
+ def _to_bedrock_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
91
+ out: List[Dict[str, Any]] = []
92
+ for t in tools:
93
+ out.append(
94
+ {
95
+ "toolSpec": {
96
+ "name": t.get("name", ""),
97
+ "description": t.get("description", ""),
98
+ "inputSchema": {"json": t.get("input_schema", {})},
99
+ }
100
+ }
101
+ )
102
+ return {"tools": out} if out else {}
103
+
104
+ @staticmethod
105
+ def _to_bedrock_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
106
+ """Bedrock content blocks differ from Anthropic's: Anthropic uses
107
+ ``{type: tool_result, tool_use_id, content}``; Bedrock uses
108
+ ``{toolResult: {toolUseId, content}}``. The format_tool_result
109
+ method emits the Bedrock shape already; this just passes things
110
+ through, only translating the simple ``{role, content: <str>}``
111
+ case where the user message is plain text."""
112
+ out: List[Dict[str, Any]] = []
113
+ for m in messages:
114
+ content = m.get("content")
115
+ if isinstance(content, str):
116
+ out.append({"role": m.get("role", "user"), "content": [{"text": content}]})
117
+ else:
118
+ out.append(m)
119
+ return out
120
+
121
+ @staticmethod
122
+ def _normalise(resp: Dict[str, Any]) -> LLMResponse:
123
+ output = (resp.get("output") or {}).get("message") or {}
124
+ blocks = output.get("content") or []
125
+ text_parts: List[str] = []
126
+ tool_calls: List[LLMToolCall] = []
127
+ for block in blocks:
128
+ if "text" in block:
129
+ text_parts.append(str(block["text"]))
130
+ elif "toolUse" in block:
131
+ tu = block["toolUse"]
132
+ tool_calls.append(
133
+ LLMToolCall(
134
+ id=str(tu.get("toolUseId") or ""),
135
+ name=str(tu.get("name") or ""),
136
+ arguments=dict(tu.get("input") or {}),
137
+ )
138
+ )
139
+ usage = resp.get("usage") or {}
140
+ # Bedrock reports `end_turn` / `tool_use` / `max_tokens` / `stop_sequence`
141
+ # in `stopReason` — same vocabulary as Anthropic, snake-cased.
142
+ stop = str(resp.get("stopReason") or "")
143
+ if stop == "endTurn":
144
+ stop = "end_turn"
145
+ elif stop == "toolUse":
146
+ stop = "tool_use"
147
+ return LLMResponse(
148
+ text="".join(text_parts),
149
+ tool_calls=tool_calls,
150
+ stop_reason=stop,
151
+ input_tokens=int(usage.get("inputTokens") or 0),
152
+ output_tokens=int(usage.get("outputTokens") or 0),
153
+ raw_assistant_message={"role": "assistant", "content": blocks},
154
+ )