petfishframework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. petfishframework/__init__.py +33 -0
  2. petfishframework/config.py +142 -0
  3. petfishframework/core/__init__.py +91 -0
  4. petfishframework/core/agent.py +207 -0
  5. petfishframework/core/compiled.py +89 -0
  6. petfishframework/core/contracts.py +190 -0
  7. petfishframework/core/conversation.py +51 -0
  8. petfishframework/core/environment.py +260 -0
  9. petfishframework/core/events.py +79 -0
  10. petfishframework/core/session.py +182 -0
  11. petfishframework/core/structured.py +202 -0
  12. petfishframework/core/types.py +192 -0
  13. petfishframework/mcp/__init__.py +19 -0
  14. petfishframework/mcp/client.py +96 -0
  15. petfishframework/mcp/server.py +14 -0
  16. petfishframework/mcp/stdio_transport.py +218 -0
  17. petfishframework/mcp/wrapper.py +43 -0
  18. petfishframework/models/__init__.py +14 -0
  19. petfishframework/models/anthropic.py +194 -0
  20. petfishframework/models/fake.py +202 -0
  21. petfishframework/models/openai.py +178 -0
  22. petfishframework/observability/__init__.py +10 -0
  23. petfishframework/observability/sinks.py +23 -0
  24. petfishframework/permissions/__init__.py +32 -0
  25. petfishframework/permissions/model.py +110 -0
  26. petfishframework/reasoning/__init__.py +13 -0
  27. petfishframework/reasoning/lats.py +222 -0
  28. petfishframework/reasoning/llm_plus_p.py +202 -0
  29. petfishframework/reasoning/react.py +176 -0
  30. petfishframework/reliability/__init__.py +78 -0
  31. petfishframework/reliability/cost.py +50 -0
  32. petfishframework/reliability/cost_report.py +101 -0
  33. petfishframework/reliability/pass_at_k.py +232 -0
  34. petfishframework/reliability/replay.py +190 -0
  35. petfishframework/reliability/retry.py +224 -0
  36. petfishframework/reliability/timeout.py +55 -0
  37. petfishframework/retrieval/__init__.py +12 -0
  38. petfishframework/retrieval/adaptive.py +137 -0
  39. petfishframework/retrieval/crag.py +135 -0
  40. petfishframework/retrieval/memory_store.py +72 -0
  41. petfishframework/tools/__init__.py +12 -0
  42. petfishframework/tools/agent_tool.py +47 -0
  43. petfishframework/tools/base.py +75 -0
  44. petfishframework/tools/calculator.py +84 -0
  45. petfishframework/tools/path_planner.py +90 -0
  46. petfishframework/tools/registry.py +158 -0
  47. petfishframework/tools/word_sorter.py +41 -0
  48. petfishframework-0.1.0.dist-info/METADATA +151 -0
  49. petfishframework-0.1.0.dist-info/RECORD +51 -0
  50. petfishframework-0.1.0.dist-info/WHEEL +4 -0
  51. petfishframework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,178 @@
1
+ """OpenAI model adapter for the ModelAdapter protocol.
2
+
3
+ The ``openai`` package is imported lazily inside ``OpenAIModel.__init__`` so the
4
+ framework core remains free of the optional OpenAI dependency at import time.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from typing import Any
11
+
12
+ from petfishframework.core.contracts import ModelAdapter
13
+ from petfishframework.core.types import (
14
+ Message,
15
+ ModelRequest,
16
+ ModelResponse,
17
+ Role,
18
+ ToolCall,
19
+ Usage,
20
+ )
21
+
22
+
23
+ class OpenAIModel(ModelAdapter):
24
+ """Adapter that queries OpenAI-compatible chat completion APIs."""
25
+
26
+ def __init__(
27
+ self,
28
+ model: str = "gpt-4o",
29
+ api_key: str | None = None,
30
+ base_url: str | None = None,
31
+ **kwargs: Any,
32
+ ) -> None:
33
+ """Create an OpenAI adapter with a lazily imported OpenAI client.
34
+
35
+ Args:
36
+ model: The OpenAI model name (e.g. ``gpt-4o``).
37
+ api_key: OpenAI API key. If not provided, ``OPENAI_API_KEY`` is used.
38
+ base_url: Optional custom base URL for the OpenAI API.
39
+ **kwargs: Additional keyword arguments forwarded to ``openai.OpenAI``.
40
+
41
+ Raises:
42
+ ImportError: If the ``openai`` package is not installed.
43
+ ValueError: If no API key is available.
44
+ """
45
+ try:
46
+ import openai
47
+ from openai import OpenAI
48
+ except ImportError as exc:
49
+ raise ImportError(
50
+ "The 'openai' package is required to use OpenAIModel. "
51
+ "Install it with 'pip install petfishframework[openai]'."
52
+ ) from exc
53
+
54
+ resolved_key = api_key if api_key is not None else os.environ.get("OPENAI_API_KEY")
55
+ if resolved_key is None:
56
+ raise ValueError(
57
+ "OpenAIModel requires an api_key or the OPENAI_API_KEY environment variable."
58
+ )
59
+
60
+ resolved_base_url = base_url if base_url is not None else os.environ.get("OPENAI_BASE_URL")
61
+
62
+ self.name = model
63
+ self._model = model
64
+ self._openai = openai
65
+ self._client = OpenAI(api_key=resolved_key, base_url=resolved_base_url, **kwargs)
66
+
67
+ def query(self, request: ModelRequest) -> ModelResponse:
68
+ """Send a chat completion request and convert the response."""
69
+ messages = self._build_messages(request.messages)
70
+ tools = self._build_tools(request.tools, request.tool_schemas)
71
+
72
+ params: dict[str, Any] = {
73
+ "model": self._model,
74
+ "messages": messages,
75
+ "temperature": request.temperature,
76
+ }
77
+ if request.max_tokens is not None:
78
+ params["max_tokens"] = request.max_tokens
79
+ if tools:
80
+ params["tools"] = tools
81
+
82
+ try:
83
+ response = self._client.chat.completions.create(**params)
84
+ except (self._openai.APIError, self._openai.RateLimitError) as exc:
85
+ raise RuntimeError(f"OpenAI API error: {exc}") from exc
86
+
87
+ return self._build_response(response)
88
+
89
+ def _build_messages(self, messages: tuple[Message, ...]) -> list[dict[str, Any]]:
90
+ """Convert framework messages to the OpenAI chat message format."""
91
+ openai_messages: list[dict[str, Any]] = []
92
+ for msg in messages:
93
+ entry: dict[str, Any] = {
94
+ "role": msg.role.value,
95
+ "content": msg.content,
96
+ }
97
+ if msg.tool_calls and msg.role is Role.ASSISTANT:
98
+ entry["tool_calls"] = [
99
+ {
100
+ "id": tool_call.id,
101
+ "type": "function",
102
+ "function": {
103
+ "name": tool_call.name,
104
+ "arguments": json.dumps(tool_call.arguments),
105
+ },
106
+ }
107
+ for tool_call in msg.tool_calls
108
+ ]
109
+ if msg.role is Role.TOOL and msg.tool_call_id is not None:
110
+ entry["tool_call_id"] = msg.tool_call_id
111
+ openai_messages.append(entry)
112
+ return openai_messages
113
+
114
+ def _build_tools(
115
+ self, tool_names: tuple[str, ...], tool_schemas: tuple[dict, ...] = ()
116
+ ) -> list[dict[str, Any]] | None:
117
+ """Convert tool definitions to OpenAI function-calling format.
118
+
119
+ Uses full schemas (name + description + parameters) when available.
120
+ Falls back to name-only if schemas not provided.
121
+ """
122
+ if not tool_names and not tool_schemas:
123
+ return None
124
+ if tool_schemas:
125
+ return [
126
+ {
127
+ "type": "function",
128
+ "function": {
129
+ "name": s.get("name", ""),
130
+ "description": s.get("description", ""),
131
+ "parameters": s.get("input_schema", {"type": "object", "properties": {}}),
132
+ },
133
+ }
134
+ for s in tool_schemas
135
+ ]
136
+ return [
137
+ {"type": "function", "function": {"name": name}}
138
+ for name in tool_names
139
+ ]
140
+
141
+ def _build_response(self, response: Any) -> ModelResponse:
142
+ """Convert an OpenAI chat completion response to a ModelResponse."""
143
+ choice = response.choices[0]
144
+ message = choice.message
145
+
146
+ tool_calls: tuple[ToolCall, ...] = ()
147
+ if message.tool_calls:
148
+ parsed_calls: list[ToolCall] = []
149
+ for tool_call in message.tool_calls:
150
+ raw_args = tool_call.function.arguments
151
+ try:
152
+ args = json.loads(raw_args) if raw_args else {}
153
+ except (json.JSONDecodeError, TypeError):
154
+ # Some providers (e.g. SiliconFlow/Qwen) return non-standard JSON.
155
+ # Fall back to wrapping the raw string in a dict.
156
+ args = {"_raw": raw_args} if raw_args else {}
157
+ parsed_calls.append(
158
+ ToolCall(
159
+ id=tool_call.id,
160
+ name=tool_call.function.name,
161
+ arguments=args,
162
+ )
163
+ )
164
+ tool_calls = tuple(parsed_calls)
165
+
166
+ usage = Usage(
167
+ input_tokens=response.usage.prompt_tokens,
168
+ output_tokens=response.usage.completion_tokens,
169
+ total_tokens=response.usage.total_tokens,
170
+ )
171
+
172
+ return ModelResponse(
173
+ content=message.content or "",
174
+ tool_calls=tool_calls,
175
+ usage=usage,
176
+ finish_reason=choice.finish_reason or "stop",
177
+ raw=response,
178
+ )
@@ -0,0 +1,10 @@
1
+ """Observability sinks consume the shared event stream.
2
+
3
+ Skeleton scope: ListSink for tests/audit and ConsoleSink for debugging.
4
+ OTel/LangSmith exporters are TODO.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .sinks import ConsoleSink, ListSink
9
+
10
+ __all__ = ["ConsoleSink", "ListSink"]
@@ -0,0 +1,23 @@
1
+ """Basic EventEmitter sinks."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from petfishframework.core.events import Event
7
+
8
+
9
+ class ListSink:
10
+ """Collects emitted events into a list for testing and audit."""
11
+
12
+ def __init__(self) -> None:
13
+ self.events: list[Event] = []
14
+
15
+ def __call__(self, event: Event) -> None:
16
+ self.events.append(event)
17
+
18
+
19
+ class ConsoleSink:
20
+ """Prints emitted events to stderr for debugging."""
21
+
22
+ def __call__(self, event: Event) -> None:
23
+ print(event, file=sys.stderr)
@@ -0,0 +1,32 @@
1
+ """Permissions package — SARC access control model (v0.2 — absorbed from agentShield-dev).
2
+
3
+ Do not constrain model thoughts. Constrain model behavior.
4
+
5
+ The skeleton ships DecisionEffect(6) + SARC types + a DefaultAllow policy.
6
+ The two-gate model (CapabilityProjection visibility + ToolCallMonitor invocation)
7
+ is structurally present but defaults to allow. Concrete enforcement, CapabilityGrant,
8
+ and CredentialBroker are TODO (see skeleton-completeness-checklist.md).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .model import (
13
+ AccessContext,
14
+ Action,
15
+ Decision,
16
+ DecisionEffect,
17
+ DefaultAllowPolicy,
18
+ PermissionPolicy,
19
+ Resource,
20
+ Subject,
21
+ )
22
+
23
+ __all__ = [
24
+ "AccessContext",
25
+ "Action",
26
+ "Decision",
27
+ "DecisionEffect",
28
+ "DefaultAllowPolicy",
29
+ "PermissionPolicy",
30
+ "Resource",
31
+ "Subject",
32
+ ]
@@ -0,0 +1,110 @@
1
+ """SARC access control model — Subject/Action/Resource/Context (v0.2).
2
+
3
+ Absorbed from agentShield-dev's battle-tested runtime access control.
4
+ Six DecisionEffect values replace binary allow/deny, enabling field-level
5
+ masking, partial allows, human-in-the-loop approval, and degradation.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any, Protocol
12
+
13
+
14
+ class DecisionEffect(Enum):
15
+ """Six effect values (not binary allow/deny). From agentShield-dev.
16
+
17
+ ALLOW — full access
18
+ DENY — no access (default for unmatched)
19
+ MASK — return masked value [MASKED:classification]
20
+ PARTIAL_ALLOW — only some arguments/fields permitted
21
+ REQUIRE_APPROVAL — needs human approval before proceeding
22
+ DEGRADE — downgrade response quality (e.g. high-risk audit fail)
23
+ """
24
+
25
+ ALLOW = "allow"
26
+ DENY = "deny"
27
+ MASK = "mask"
28
+ PARTIAL_ALLOW = "partial_allow"
29
+ REQUIRE_APPROVAL = "require_approval"
30
+ DEGRADE = "degrade"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # SARC model (Subject-Action-Resource-Context)
35
+ # ---------------------------------------------------------------------------
36
+
37
+ @dataclass(frozen=True)
38
+ class Subject:
39
+ """Who is requesting (the agent/user identity vector)."""
40
+
41
+ user_id: str = "anonymous"
42
+ roles: tuple[str, ...] = ()
43
+ clearance: str = "public" # matches Clearance enum values
44
+ projects: tuple[str, ...] = ()
45
+ tenant_id: str = "default"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Action:
50
+ """What operation is being requested."""
51
+
52
+ type: str # read | call | write | execute | retrieve | send
53
+ tool_name: str | None = None
54
+ args: dict[str, Any] = field(default_factory=dict)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Resource:
59
+ """What is being acted upon."""
60
+
61
+ type: str = "tool" # tool | data | output | model
62
+ classification: str = "public"
63
+ tags: tuple[str, ...] = ()
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class AccessContext:
68
+ """Runtime conditions for the decision."""
69
+
70
+ session_id: str = ""
71
+ prompt_risk: float = 0.0
72
+ session_risk: float = 0.0
73
+ step: int = 0
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Decision:
78
+ """An authorization decision with effect, reason, and constraints."""
79
+
80
+ effect: DecisionEffect
81
+ reason: str = ""
82
+ allowed_fields: tuple[str, ...] | None = None # for PARTIAL_ALLOW
83
+ masked_fields: tuple[str, ...] | None = None # for MASK
84
+ constraints: dict[str, Any] = field(default_factory=dict)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Permission policy contract
89
+ # ---------------------------------------------------------------------------
90
+
91
+ class PermissionPolicy(Protocol):
92
+ """Evaluates authorization decisions. Implementations plug in SARC backends."""
93
+
94
+ def evaluate(self, subject: Subject, action: Action, resource: Resource, context: AccessContext) -> Decision:
95
+ ...
96
+
97
+
98
+ @dataclass
99
+ class DefaultAllowPolicy:
100
+ """Skeleton default: allow everything. Real policies replace this.
101
+
102
+ The gate STRUCTURE exists (Environment calls this before every tool
103
+ invocation) but defaults to ALLOW. This ensures the permission chokepoint
104
+ is wired from day one — flipping to deny-by-default is a config change,
105
+ not an architecture change.
106
+ """
107
+
108
+ def evaluate(self, subject: Subject, action: Action, resource: Resource, context: AccessContext) -> Decision:
109
+ # Skeleton: allow all. TODO: wire SARC policy engine + two-gate model.
110
+ return Decision(effect=DecisionEffect.ALLOW, reason="default_allow_policy")
@@ -0,0 +1,13 @@
1
+ """Reasoning strategies — pluggable search algorithms over Environment.
2
+
3
+ V2 adds LATS (Language Agent Tree Search) and LLM+P (LLM + Symbolic Planner)
4
+ next to ReAct, validating that all three fit the same ReasoningStrategy
5
+ interface without modifying core contracts.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .lats import LATS
10
+ from .llm_plus_p import LLMPlusP
11
+ from .react import ReAct
12
+
13
+ __all__ = ["LATS", "LLMPlusP", "ReAct"]
@@ -0,0 +1,222 @@
1
+ """LATS (Language Agent Tree Search) reasoning strategy — V2 simplified.
2
+
3
+ This is a deliberately simplified LATS that validates the ReasoningStrategy
4
+ interface fit (decision 3 / open question 2). It generates candidate next
5
+ actions, scores them with the model, selects the best, executes it, and
6
+ repeats. Full MCTS with UCB/rollout/backpropagation is Phase 4.
7
+
8
+ All capability access flows through ctx.env; the Environment interface is
9
+ completely unchanged.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ from petfishframework.core.contracts import ReasoningStrategy
17
+ from petfishframework.core.types import (
18
+ BudgetExceeded,
19
+ Message,
20
+ ModelRequest,
21
+ Result,
22
+ Role,
23
+ Step,
24
+ ToolCall,
25
+ ToolRef,
26
+ Trajectory,
27
+ Usage,
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class LATS(ReasoningStrategy):
33
+ """Simplified Language Agent Tree Search over the Environment.
34
+
35
+ Fields:
36
+ breadth: Number of candidate next actions generated per expansion.
37
+ max_depth: Maximum search depth (tool-execution steps).
38
+ name: Strategy identifier.
39
+ """
40
+
41
+ breadth: int = 3
42
+ max_depth: int = 5
43
+ name: str = "lats"
44
+
45
+ def run(self, ctx) -> Result:
46
+ """Run the simplified LATS loop within the provided RunContext."""
47
+ tools = ctx.env.tools()
48
+ tool_names = tuple(t.name for t in tools)
49
+
50
+ system_prompt = self._system_prompt(tools)
51
+ messages = [
52
+ Message(role=Role.SYSTEM, content=system_prompt),
53
+ Message(role=Role.USER, content=ctx.task.prompt),
54
+ ]
55
+
56
+ steps: list[Step] = []
57
+ usage = Usage()
58
+ max_depth = ctx.budget.max_steps if ctx.budget.max_steps is not None else self.max_depth
59
+
60
+ try:
61
+ for depth in range(max_depth):
62
+ # -----------------------------------------------------------------
63
+ # 1. Expand: generate candidate next actions.
64
+ # -----------------------------------------------------------------
65
+ expand_request = ModelRequest(messages=tuple(messages), tools=tool_names)
66
+ expand_response = ctx.env.query_model(expand_request)
67
+ usage = usage.add(expand_response.usage)
68
+
69
+ candidates = expand_response.tool_calls
70
+ ctx.events.emit(
71
+ "lats.expand",
72
+ {
73
+ "depth": depth,
74
+ "breadth": self.breadth,
75
+ "candidate_count": len(candidates),
76
+ },
77
+ )
78
+
79
+ # Termination: model produced a final answer without tool calls.
80
+ if not candidates:
81
+ steps.append(Step(thought=expand_response.content))
82
+ return Result(
83
+ answer=expand_response.content,
84
+ trajectory=Trajectory(steps=tuple(steps)),
85
+ usage=usage,
86
+ )
87
+
88
+ # Limit candidates to configured breadth.
89
+ candidates = candidates[: self.breadth]
90
+
91
+ # -----------------------------------------------------------------
92
+ # 2. Evaluate: score each candidate.
93
+ # -----------------------------------------------------------------
94
+ scored: list[tuple[ToolCall, float]] = []
95
+ for candidate in candidates:
96
+ score, score_usage = self._score_candidate(
97
+ ctx,
98
+ messages,
99
+ candidate,
100
+ )
101
+ usage = usage.add(score_usage)
102
+ scored.append((candidate, score))
103
+ ctx.events.emit(
104
+ "lats.evaluate",
105
+ {
106
+ "depth": depth,
107
+ "tool_name": candidate.name,
108
+ "tool_args": candidate.arguments,
109
+ "score": score,
110
+ },
111
+ )
112
+
113
+ # -----------------------------------------------------------------
114
+ # 3. Select: highest-scoring candidate.
115
+ # -----------------------------------------------------------------
116
+ best_candidate, best_score = max(scored, key=lambda item: item[1])
117
+ ctx.events.emit(
118
+ "lats.select",
119
+ {
120
+ "depth": depth,
121
+ "tool_name": best_candidate.name,
122
+ "tool_args": best_candidate.arguments,
123
+ "score": best_score,
124
+ },
125
+ )
126
+
127
+ # -----------------------------------------------------------------
128
+ # 4. Execute: run the selected action and observe.
129
+ # -----------------------------------------------------------------
130
+ tool_result = ctx.env.call(
131
+ ToolRef(name=best_candidate.name),
132
+ best_candidate.arguments,
133
+ )
134
+ observation = (
135
+ tool_result.error if tool_result.error is not None else str(tool_result.value)
136
+ )
137
+ steps.append(
138
+ Step(
139
+ thought=f"Depth {depth}: selected {best_candidate.name} "
140
+ f"with score {best_score}",
141
+ tool_name=best_candidate.name,
142
+ tool_args=best_candidate.arguments,
143
+ observation=observation,
144
+ )
145
+ )
146
+
147
+ # Append selected action + observation to conversation history.
148
+ messages.append(
149
+ Message(
150
+ role=Role.ASSISTANT,
151
+ content=f"Selected action: {best_candidate.name}({best_candidate.arguments})",
152
+ tool_calls=(best_candidate,),
153
+ )
154
+ )
155
+ messages.append(
156
+ Message(
157
+ role=Role.TOOL,
158
+ content=observation,
159
+ tool_call_id=best_candidate.id,
160
+ )
161
+ )
162
+ except BudgetExceeded:
163
+ # Hard budget hit: return whatever trajectory we have so far.
164
+ return Result(
165
+ answer=messages[-1].content if messages else "",
166
+ trajectory=Trajectory(steps=tuple(steps)),
167
+ usage=usage,
168
+ )
169
+
170
+ # Max depth reached without a final answer — return the best observation.
171
+ final_answer = messages[-1].content if messages else ""
172
+ return Result(
173
+ answer=final_answer,
174
+ trajectory=Trajectory(steps=tuple(steps)),
175
+ usage=usage,
176
+ )
177
+
178
+ def _score_candidate(
179
+ self,
180
+ ctx,
181
+ messages: list[Message],
182
+ candidate: ToolCall,
183
+ ) -> tuple[float, Usage]:
184
+ """Ask the model to score a candidate action on a 0-10 scale."""
185
+ scoring_messages = list(messages) + [
186
+ Message(
187
+ role=Role.USER,
188
+ content=(
189
+ f"Candidate action: {candidate.name}({candidate.arguments}).\n"
190
+ "Rate how useful this action is for completing the task, "
191
+ "on a scale from 0 to 10. Respond with just a number."
192
+ ),
193
+ )
194
+ ]
195
+ request = ModelRequest(messages=tuple(scoring_messages), tools=())
196
+ response = ctx.env.query_model(request)
197
+ score = self._parse_score(response.content)
198
+ return score, response.usage
199
+
200
+ @staticmethod
201
+ def _parse_score(content: str) -> float:
202
+ """Extract the first numeric value from a scoring response."""
203
+ match = re.search(r"\d+(?:\.\d+)?", content)
204
+ if match:
205
+ return float(match.group())
206
+ return 0.0
207
+
208
+ def _system_prompt(self, tools: list) -> str:
209
+ """Build a system prompt describing available tools and the LATS protocol."""
210
+ tool_lines = []
211
+ for tool in tools:
212
+ tool_lines.append(f"- {tool.name}: {tool.description}")
213
+ tool_text = "\n".join(tool_lines) if tool_lines else "(no tools available)"
214
+
215
+ return (
216
+ "You are a helpful assistant that searches for the best next action.\n"
217
+ "Available tools:\n" + tool_text + "\n"
218
+ "When asked for candidate actions, respond with up to "
219
+ f"{self.breadth} tool calls. "
220
+ "When asked to rate a candidate, respond with a single number 0-10. "
221
+ "When you have enough information, respond with plain text and no tool calls."
222
+ )