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.
- petfishframework/__init__.py +33 -0
- petfishframework/config.py +142 -0
- petfishframework/core/__init__.py +91 -0
- petfishframework/core/agent.py +207 -0
- petfishframework/core/compiled.py +89 -0
- petfishframework/core/contracts.py +190 -0
- petfishframework/core/conversation.py +51 -0
- petfishframework/core/environment.py +260 -0
- petfishframework/core/events.py +79 -0
- petfishframework/core/session.py +182 -0
- petfishframework/core/structured.py +202 -0
- petfishframework/core/types.py +192 -0
- petfishframework/mcp/__init__.py +19 -0
- petfishframework/mcp/client.py +96 -0
- petfishframework/mcp/server.py +14 -0
- petfishframework/mcp/stdio_transport.py +218 -0
- petfishframework/mcp/wrapper.py +43 -0
- petfishframework/models/__init__.py +14 -0
- petfishframework/models/anthropic.py +194 -0
- petfishframework/models/fake.py +202 -0
- petfishframework/models/openai.py +178 -0
- petfishframework/observability/__init__.py +10 -0
- petfishframework/observability/sinks.py +23 -0
- petfishframework/permissions/__init__.py +32 -0
- petfishframework/permissions/model.py +110 -0
- petfishframework/reasoning/__init__.py +13 -0
- petfishframework/reasoning/lats.py +222 -0
- petfishframework/reasoning/llm_plus_p.py +202 -0
- petfishframework/reasoning/react.py +176 -0
- petfishframework/reliability/__init__.py +78 -0
- petfishframework/reliability/cost.py +50 -0
- petfishframework/reliability/cost_report.py +101 -0
- petfishframework/reliability/pass_at_k.py +232 -0
- petfishframework/reliability/replay.py +190 -0
- petfishframework/reliability/retry.py +224 -0
- petfishframework/reliability/timeout.py +55 -0
- petfishframework/retrieval/__init__.py +12 -0
- petfishframework/retrieval/adaptive.py +137 -0
- petfishframework/retrieval/crag.py +135 -0
- petfishframework/retrieval/memory_store.py +72 -0
- petfishframework/tools/__init__.py +12 -0
- petfishframework/tools/agent_tool.py +47 -0
- petfishframework/tools/base.py +75 -0
- petfishframework/tools/calculator.py +84 -0
- petfishframework/tools/path_planner.py +90 -0
- petfishframework/tools/registry.py +158 -0
- petfishframework/tools/word_sorter.py +41 -0
- petfishframework-0.1.0.dist-info/METADATA +151 -0
- petfishframework-0.1.0.dist-info/RECORD +51 -0
- petfishframework-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|