ldp 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.
- ldp/__init__.py +0 -0
- ldp/agent/__init__.py +33 -0
- ldp/agent/agent.py +168 -0
- ldp/agent/agent_client.py +147 -0
- ldp/agent/dqn_agent.py +154 -0
- ldp/agent/memory_agent.py +100 -0
- ldp/agent/react_agent.py +140 -0
- ldp/agent/simple_agent.py +102 -0
- ldp/agent/tree_of_thoughts_agent.py +142 -0
- ldp/alg/__init__.py +0 -0
- ldp/alg/algorithms.py +167 -0
- ldp/alg/beam_search.py +218 -0
- ldp/alg/callbacks.py +377 -0
- ldp/alg/datasets.py +11 -0
- ldp/alg/optimizer/__init__.py +96 -0
- ldp/alg/optimizer/ape.py +297 -0
- ldp/alg/optimizer/dqn.py +492 -0
- ldp/alg/optimizer/memory.py +108 -0
- ldp/alg/optimizer/openai_sft_optimizer.py +309 -0
- ldp/alg/optimizer/opt.py +50 -0
- ldp/alg/optimizer/replay_buffers.py +34 -0
- ldp/alg/rollout.py +309 -0
- ldp/alg/runners.py +313 -0
- ldp/alg/tree_search.py +149 -0
- ldp/data_structures.py +112 -0
- ldp/graph/__init__.py +0 -0
- ldp/graph/async_torch.py +222 -0
- ldp/graph/common_ops.py +370 -0
- ldp/graph/gradient_estimators.py +144 -0
- ldp/graph/memory.py +146 -0
- ldp/graph/modules/__init__.py +31 -0
- ldp/graph/modules/llm_call.py +30 -0
- ldp/graph/modules/react.py +272 -0
- ldp/graph/modules/reflect.py +59 -0
- ldp/graph/modules/thought.py +44 -0
- ldp/graph/modules/value_function.py +366 -0
- ldp/graph/op_utils.py +148 -0
- ldp/graph/ops.py +513 -0
- ldp/graph/pydantic_patch.py +50 -0
- ldp/graph/torch_ops.py +137 -0
- ldp/llms/__init__.py +41 -0
- ldp/llms/chat.py +351 -0
- ldp/llms/embeddings.py +134 -0
- ldp/llms/prompts.py +100 -0
- ldp/py.typed +0 -0
- ldp/version.py +16 -0
- ldp-0.1.0.dist-info/LICENSE +201 -0
- ldp-0.1.0.dist-info/METADATA +138 -0
- ldp-0.1.0.dist-info/RECORD +51 -0
- ldp-0.1.0.dist-info/WHEEL +5 -0
- ldp-0.1.0.dist-info/top_level.txt +1 -0
ldp/__init__.py
ADDED
|
File without changes
|
ldp/agent/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DefaultLLMModelNames(StrEnum):
|
|
5
|
+
"""Defaults for LLM models, pin exact versions for performance stability."""
|
|
6
|
+
|
|
7
|
+
OPENAI = "gpt-4o-2024-08-06" # Cheap, fast, and decent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ruff: noqa: E402 # Avoid circular imports
|
|
11
|
+
|
|
12
|
+
from .agent import Agent, AgentConfig
|
|
13
|
+
from .agent_client import HTTPAgentClient, make_simple_agent_server
|
|
14
|
+
from .dqn_agent import DQNAgent, MultipleCompletionLLMCallOp
|
|
15
|
+
from .memory_agent import MemoryAgent
|
|
16
|
+
from .react_agent import ReActAgent
|
|
17
|
+
from .simple_agent import SimpleAgent, SimpleAgentState
|
|
18
|
+
from .tree_of_thoughts_agent import TreeofThoughtsAgent
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Agent",
|
|
22
|
+
"AgentConfig",
|
|
23
|
+
"DQNAgent",
|
|
24
|
+
"DefaultLLMModelNames",
|
|
25
|
+
"HTTPAgentClient",
|
|
26
|
+
"MemoryAgent",
|
|
27
|
+
"MultipleCompletionLLMCallOp",
|
|
28
|
+
"ReActAgent",
|
|
29
|
+
"SimpleAgent",
|
|
30
|
+
"SimpleAgentState",
|
|
31
|
+
"TreeofThoughtsAgent",
|
|
32
|
+
"make_simple_agent_server",
|
|
33
|
+
]
|
ldp/agent/agent.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from aviary.message import Message
|
|
10
|
+
from aviary.tools import Tool, ToolRequestMessage
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
12
|
+
|
|
13
|
+
from ldp.graph.ops import Op, OpResult
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# So we can skip torch objects when looking for Ops
|
|
17
|
+
import torch
|
|
18
|
+
except ImportError:
|
|
19
|
+
# If torch is not available, then it won't be used in an Agent anyway
|
|
20
|
+
torch = None # type: ignore[assignment]
|
|
21
|
+
|
|
22
|
+
TAgentState = TypeVar("TAgentState")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# A global registry of all Agent subclasses, so we can look them up by name
|
|
26
|
+
_AGENT_REGISTRY: dict[str, type[Agent]] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Agent(ABC, Generic[TAgentState]):
|
|
30
|
+
def __init_subclass__(cls, **kwargs):
|
|
31
|
+
"""Ensure Ops have unique names and subclasses are in _AGENT_REGISTRY."""
|
|
32
|
+
super().__init_subclass__(**kwargs)
|
|
33
|
+
|
|
34
|
+
original_init = cls.__init__
|
|
35
|
+
|
|
36
|
+
def init_with_op_naming(self, *args, **kwargs):
|
|
37
|
+
original_init(self, *args, **kwargs)
|
|
38
|
+
|
|
39
|
+
# loop through Ops and give them proper names
|
|
40
|
+
for name, op in _find_ops(self):
|
|
41
|
+
op.set_name(name)
|
|
42
|
+
|
|
43
|
+
cls.__init__ = init_with_op_naming # type: ignore[method-assign]
|
|
44
|
+
|
|
45
|
+
# Register the Agent subclass.
|
|
46
|
+
_AGENT_REGISTRY[cls.__name__] = cls
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def get_asv(
|
|
50
|
+
self, agent_state: TAgentState, obs: list[Message]
|
|
51
|
+
) -> tuple[OpResult[ToolRequestMessage], TAgentState, float]:
|
|
52
|
+
"""
|
|
53
|
+
Get new action, state, and value given state and observation messages.
|
|
54
|
+
|
|
55
|
+
NOTE: the method's name has action listed before state to help you realize it's
|
|
56
|
+
a new state.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
agent_state: Optional current agent state, pass None if irrelevant.
|
|
60
|
+
This can be something like agent memory.
|
|
61
|
+
obs: Most recent list of observation messages from the environment's steps.
|
|
62
|
+
If more observations than the most recent list are necessary, track them
|
|
63
|
+
in the agent state.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Three-tuple of new action, new agent state, and estimated value. The
|
|
67
|
+
agent_state is returned as a copy so that you can safely mutate it
|
|
68
|
+
without affecting the original. The estimated value is the agent's
|
|
69
|
+
estimate of the future rewards given the input state and observations,
|
|
70
|
+
and is used for RL training. If estimated value doesn't matter, just
|
|
71
|
+
return 0. The value could also come from a Q-value evaluated at the
|
|
72
|
+
action chosen by the agent.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def init_state(self, tools: list[Tool]) -> TAgentState:
|
|
77
|
+
"""Initializes the first agent state with the provided tools."""
|
|
78
|
+
|
|
79
|
+
def named_ops(self) -> Iterable[tuple[str, Op]]:
|
|
80
|
+
"""Analogous to torch.nn.Module.named_parameters()."""
|
|
81
|
+
return _find_ops(self)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AgentConfig(BaseModel):
|
|
85
|
+
"""Configuration for specifying the type of agent i.e. the subclass of Agent above."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
agent_type: str = Field(
|
|
90
|
+
description="The type of agent to be used. "
|
|
91
|
+
"This should be a subclass of Agent above.",
|
|
92
|
+
)
|
|
93
|
+
agent_kwargs: dict[str, JsonValue] = Field(
|
|
94
|
+
default_factory=dict,
|
|
95
|
+
description="Keyword arguments to pass to the agent's constructor.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def construct_agent(self) -> Agent:
|
|
99
|
+
return _AGENT_REGISTRY[self.agent_type](**self.agent_kwargs)
|
|
100
|
+
|
|
101
|
+
def __hash__(self) -> int:
|
|
102
|
+
return hash(self.agent_type + json.dumps(self.agent_kwargs, sort_keys=True))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _find_ops( # noqa: C901
|
|
106
|
+
root: object, root_name: str = "", visited: set[int] | None = None
|
|
107
|
+
) -> Iterable[tuple[str, Op]]:
|
|
108
|
+
"""Recursive function to find children that are Ops and the attr chain to reach them.
|
|
109
|
+
|
|
110
|
+
E.g. if root.module.op is an Op, then we will yield ("module.op", root.module.op).
|
|
111
|
+
These are not fully qualified names, but more like "locally qualified names". In the above
|
|
112
|
+
example, "root." + "module.op" is the fully qualified name.
|
|
113
|
+
This is an internal function - Agent.named_ops() should usually suffice.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
root: Any object that might hold Ops.
|
|
117
|
+
root_name: The name of the root object. Defaults to empty string and is passed as an arg to
|
|
118
|
+
make this method recursive.
|
|
119
|
+
visited: a set of visited node IDs to avoid loops. Defaults to None.
|
|
120
|
+
|
|
121
|
+
Yields:
|
|
122
|
+
Two-tuple of (locally qualified name, Op) pairs
|
|
123
|
+
"""
|
|
124
|
+
# Recursive function to find children that are Ops and the
|
|
125
|
+
# attribute chain to reach them.
|
|
126
|
+
if visited is None:
|
|
127
|
+
visited = set()
|
|
128
|
+
|
|
129
|
+
if isinstance(root, Op):
|
|
130
|
+
yield root_name, root
|
|
131
|
+
# Assume an Op may not have sub-Ops. I think this is sound, since
|
|
132
|
+
# we wouldn't be tracking the compute graph properly if it did.
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if "__pydantic_parent_namespace__" in root_name:
|
|
136
|
+
# Skip Pydantic internals
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Don't recurse into PyTorch objects because they won't contain Ops
|
|
140
|
+
if torch is not None and ( # type: ignore[redundant-expr]
|
|
141
|
+
isinstance(root, torch.Tensor | torch.nn.Module)
|
|
142
|
+
):
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Similarly for numpy
|
|
146
|
+
if isinstance(root, np.ndarray):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# loop through 3 types of containers: dicts, collections, and objects
|
|
150
|
+
if isinstance(root, Mapping):
|
|
151
|
+
named_attrs: Any = root.items()
|
|
152
|
+
elif isinstance(root, Sequence | Collection) and not isinstance(root, str | bytes):
|
|
153
|
+
named_attrs = enumerate(root)
|
|
154
|
+
elif hasattr(root, "__dict__"):
|
|
155
|
+
# object?
|
|
156
|
+
named_attrs = root.__dict__.items()
|
|
157
|
+
else:
|
|
158
|
+
# couldn't descend
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
for k, v in named_attrs:
|
|
162
|
+
id_v = id(v)
|
|
163
|
+
if id_v not in visited:
|
|
164
|
+
# only visit each object once - avoid loops, etc.
|
|
165
|
+
visited.add(id_v)
|
|
166
|
+
if root_name:
|
|
167
|
+
k = f"{root_name}.{k}"
|
|
168
|
+
yield from _find_ops(v, root_name=k, visited=visited)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import secrets
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated, TypeVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from aviary.message import Message
|
|
7
|
+
from aviary.tools import Messages, Tool, ToolRequestMessage, ToolsAdapter
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from ldp.graph.op_utils import get_training_mode
|
|
11
|
+
from ldp.graph.ops import OpResult
|
|
12
|
+
|
|
13
|
+
from .agent import Agent
|
|
14
|
+
from .simple_agent import SimpleAgentState
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
|
|
19
|
+
TSerializableAgentState = TypeVar("TSerializableAgentState", bound=BaseModel)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HTTPAgentClient(Agent[TSerializableAgentState]):
|
|
23
|
+
"""Interact with an Agent running in a server via POST requests."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
agent_state_type: type[TSerializableAgentState],
|
|
28
|
+
server_url: str,
|
|
29
|
+
request_headers: httpx._types.HeaderTypes | None = None,
|
|
30
|
+
request_timeout: float | None = None,
|
|
31
|
+
):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self._agent_state_type = agent_state_type
|
|
34
|
+
self._request_url = server_url
|
|
35
|
+
self._request_headers = request_headers
|
|
36
|
+
self._request_timeout = request_timeout
|
|
37
|
+
|
|
38
|
+
async def get_asv(
|
|
39
|
+
self,
|
|
40
|
+
agent_state: TSerializableAgentState,
|
|
41
|
+
obs: list[Message],
|
|
42
|
+
) -> tuple[OpResult[ToolRequestMessage], TSerializableAgentState, float]:
|
|
43
|
+
async with httpx.AsyncClient() as client:
|
|
44
|
+
response = await client.post(
|
|
45
|
+
f"{self._request_url}/get_asv",
|
|
46
|
+
json={
|
|
47
|
+
"agent_state": agent_state.model_dump(),
|
|
48
|
+
"obs": [m.model_dump() for m in obs],
|
|
49
|
+
"training": get_training_mode(),
|
|
50
|
+
},
|
|
51
|
+
headers=self._request_headers,
|
|
52
|
+
timeout=self._request_timeout,
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
response_data = response.json()
|
|
56
|
+
return (
|
|
57
|
+
OpResult[ToolRequestMessage](**response_data[0]),
|
|
58
|
+
self._agent_state_type(**response_data[1]),
|
|
59
|
+
response_data[2],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def init_state(self, tools: list[Tool]) -> TSerializableAgentState:
|
|
63
|
+
async with httpx.AsyncClient() as client:
|
|
64
|
+
response = await client.post(
|
|
65
|
+
f"{self._request_url}/init_state",
|
|
66
|
+
json=ToolsAdapter.dump_python(tools),
|
|
67
|
+
headers=self._request_headers,
|
|
68
|
+
timeout=self._request_timeout,
|
|
69
|
+
)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return self._agent_state_type(**response.json())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def make_simple_agent_server(
|
|
75
|
+
agent: Agent[SimpleAgentState], render_docs: bool = False
|
|
76
|
+
) -> "FastAPI":
|
|
77
|
+
"""
|
|
78
|
+
Make a FastAPI app designed to work with the above HTTPAgentClient.
|
|
79
|
+
|
|
80
|
+
Here's how this works:
|
|
81
|
+
1. There is an entity orchestrating an Agent's interactions with an Environment.
|
|
82
|
+
A simple example of this is an integration test that sequentially calls
|
|
83
|
+
Agent.get_asv and Environment.step.
|
|
84
|
+
2. That entity is given the above HTTPAgentClient. Any Agent.init_state or
|
|
85
|
+
Agent.get_asv calls the orchestration entity makes are actually
|
|
86
|
+
POST requests under the hood. The agent's "brains" aren't local.
|
|
87
|
+
3. Remotely, this server code is running, and is where the actual Agent logic lives.
|
|
88
|
+
An example of this is a remote server containing GPU(s).
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
from fastapi import Body, Depends, FastAPI, HTTPException, status
|
|
92
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
93
|
+
except ModuleNotFoundError as exc:
|
|
94
|
+
raise ImportError(
|
|
95
|
+
"Please install aviary with the 'server' extra like so:"
|
|
96
|
+
" `pip install aviary[server]`."
|
|
97
|
+
) from exc
|
|
98
|
+
|
|
99
|
+
asgi_app = FastAPI(
|
|
100
|
+
title=f"aviary.Agent {type(agent).__name__}",
|
|
101
|
+
description="Serve inference endpoints for an aviary.Agent with a SimpleAgentState",
|
|
102
|
+
# Only render Swagger docs if local since we don't have a login here
|
|
103
|
+
docs_url="/docs" if render_docs else None,
|
|
104
|
+
redoc_url="/redoc" if render_docs else None,
|
|
105
|
+
)
|
|
106
|
+
auth_scheme = HTTPBearer()
|
|
107
|
+
|
|
108
|
+
async def validate_token(
|
|
109
|
+
token: Annotated[HTTPAuthorizationCredentials, Depends(auth_scheme)],
|
|
110
|
+
) -> HTTPAuthorizationCredentials:
|
|
111
|
+
# NOTE: don't use os.environ.get() to avoid possible empty string matches, and
|
|
112
|
+
# to have clearer server failures if the AUTH_TOKEN env var isn't present
|
|
113
|
+
if not secrets.compare_digest(token.credentials, os.environ["AUTH_TOKEN"]):
|
|
114
|
+
raise HTTPException(
|
|
115
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
116
|
+
detail="Incorrect bearer token",
|
|
117
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
118
|
+
)
|
|
119
|
+
return token
|
|
120
|
+
|
|
121
|
+
@asgi_app.get("/info")
|
|
122
|
+
def info(
|
|
123
|
+
_: Annotated[HTTPAuthorizationCredentials, Depends(validate_token)],
|
|
124
|
+
) -> dict[str, str]:
|
|
125
|
+
"""Get agent metadata, useful for debugging."""
|
|
126
|
+
return {"agent_type": type(agent).__name__}
|
|
127
|
+
|
|
128
|
+
@asgi_app.post("/get_asv")
|
|
129
|
+
async def get_asv(
|
|
130
|
+
agent_state: SimpleAgentState,
|
|
131
|
+
obs: Messages,
|
|
132
|
+
_: Annotated[HTTPAuthorizationCredentials, Depends(validate_token)],
|
|
133
|
+
training: Annotated[bool, Body()] = True,
|
|
134
|
+
) -> tuple[OpResult[ToolRequestMessage], SimpleAgentState, float]:
|
|
135
|
+
if training:
|
|
136
|
+
raise NotImplementedError("Training is not yet supported.")
|
|
137
|
+
action, agent_state, vhat = await agent.get_asv(agent_state, obs)
|
|
138
|
+
return action, agent_state, vhat
|
|
139
|
+
|
|
140
|
+
@asgi_app.post("/init_state")
|
|
141
|
+
async def init_state(
|
|
142
|
+
tools: list[Tool],
|
|
143
|
+
_: Annotated[HTTPAuthorizationCredentials, Depends(validate_token)],
|
|
144
|
+
) -> SimpleAgentState:
|
|
145
|
+
return await agent.init_state(tools)
|
|
146
|
+
|
|
147
|
+
return asgi_app
|
ldp/agent/dqn_agent.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
from aviary.message import Message
|
|
5
|
+
from aviary.tools import Tool, ToolRequestMessage
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from ldp.graph.common_ops import FxnOp, LLMCallOp
|
|
9
|
+
from ldp.graph.modules import DQNOp, DQNPolicyModule
|
|
10
|
+
from ldp.graph.op_utils import compute_graph, get_call_id
|
|
11
|
+
from ldp.graph.ops import OpResult
|
|
12
|
+
from ldp.llms import MultipleCompletionLLMModel, prepend_sys
|
|
13
|
+
|
|
14
|
+
from . import DefaultLLMModelNames
|
|
15
|
+
from .agent import Agent
|
|
16
|
+
from .simple_agent import SimpleAgentState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MultipleCompletionLLMCallOp(LLMCallOp):
|
|
20
|
+
"""Like LLMCallOp, but samples multiple completions (n>1) for a given input."""
|
|
21
|
+
|
|
22
|
+
async def forward( # type: ignore[override]
|
|
23
|
+
self,
|
|
24
|
+
config: dict,
|
|
25
|
+
msgs: list[Message],
|
|
26
|
+
tools: list[Tool] | None = None,
|
|
27
|
+
tool_choice: Tool
|
|
28
|
+
| str
|
|
29
|
+
| None = MultipleCompletionLLMModel.TOOL_CHOICE_REQUIRED,
|
|
30
|
+
) -> list[Message]:
|
|
31
|
+
model = MultipleCompletionLLMModel(config=config)
|
|
32
|
+
|
|
33
|
+
results = await model.call(messages=msgs, tools=tools, tool_choice=tool_choice)
|
|
34
|
+
if not results:
|
|
35
|
+
raise ValueError("No completions returned from the model.")
|
|
36
|
+
|
|
37
|
+
# All completions have the same config, so check the first one
|
|
38
|
+
temperature: float = (results[0].config or {}).get("temperature", 1.0)
|
|
39
|
+
|
|
40
|
+
# Compute a Monte Carlo estimate of the logprob of this sequence at the given temperature.
|
|
41
|
+
logprobs = await asyncio.gather(*[
|
|
42
|
+
self.compute_logprob(
|
|
43
|
+
raw_log_p=result.logprob,
|
|
44
|
+
temperature=temperature,
|
|
45
|
+
model=model,
|
|
46
|
+
messages=msgs,
|
|
47
|
+
tools=tools,
|
|
48
|
+
tool_choice=tool_choice,
|
|
49
|
+
)
|
|
50
|
+
for result in results
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
call_id = get_call_id()
|
|
54
|
+
self.ctx.update(call_id, "results", results)
|
|
55
|
+
# This is the logprob of this sequence according to the raw model, without
|
|
56
|
+
# any temperature/top-p distribution shaping.
|
|
57
|
+
self.ctx.update(call_id, "raw_logprobs", [result.logprob for result in results])
|
|
58
|
+
|
|
59
|
+
self.ctx.update(call_id, "temperature", temperature)
|
|
60
|
+
self.ctx.update(call_id, "logprob", logprobs)
|
|
61
|
+
|
|
62
|
+
return [cast(list[Message], result.messages)[0] for result in results]
|
|
63
|
+
|
|
64
|
+
# set the return type to list[Message], not LLMCallOp's inherited Message
|
|
65
|
+
async def __call__( # type: ignore[override]
|
|
66
|
+
self, *args, **kwargs
|
|
67
|
+
) -> OpResult[list[Message]]:
|
|
68
|
+
return await super().__call__(*args, **kwargs) # type: ignore[return-value]
|
|
69
|
+
|
|
70
|
+
async def compute_logprob(
|
|
71
|
+
self,
|
|
72
|
+
raw_log_p: float | None,
|
|
73
|
+
temperature: float,
|
|
74
|
+
model: MultipleCompletionLLMModel,
|
|
75
|
+
**model_kwargs,
|
|
76
|
+
) -> float | None:
|
|
77
|
+
"""This method computes a Monte Carlo estimate of logprob for a given temperature."""
|
|
78
|
+
return None # TODO: finish implementing this using n>1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DQNAgent(BaseModel, Agent[SimpleAgentState]):
|
|
82
|
+
"""An agent that trains a state-action value function Q(s,a) to select actions.
|
|
83
|
+
|
|
84
|
+
This a modification of traditional DQNs [1]. When using a vanilla DQN, the action
|
|
85
|
+
space is assumed to be enumerable, enabling greedy action selection. When sampling
|
|
86
|
+
from an LLM, the action space is large. So instead, we sample from the LLM and use
|
|
87
|
+
the Q network to score the sampled actions. We are then greedy among the sampled
|
|
88
|
+
actions.
|
|
89
|
+
|
|
90
|
+
[1] https://arxiv.org/abs/1312.5602
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# Not frozen because we want to change num_actions_to_sample
|
|
94
|
+
model_config = ConfigDict(frozen=False, arbitrary_types_allowed=True)
|
|
95
|
+
|
|
96
|
+
num_actions_to_sample: int = Field(
|
|
97
|
+
default=1,
|
|
98
|
+
description="Number of actions to sample from the LLM. "
|
|
99
|
+
"If >1, the Q function will be used to select the "
|
|
100
|
+
"highest-scoring action.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
llm_model: dict[str, Any] = Field(
|
|
104
|
+
default={"model": DefaultLLMModelNames.OPENAI.value, "temperature": 1.0},
|
|
105
|
+
description="Model configuration (not trained). "
|
|
106
|
+
"Setting a high default temperature to encourage exploration.",
|
|
107
|
+
)
|
|
108
|
+
sys_prompt: str = Field(
|
|
109
|
+
default="Using tools, complete the given task.",
|
|
110
|
+
description="System prompt. Trainable",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
dqn: DQNOp | None = None,
|
|
116
|
+
epsilon: float = 0.0,
|
|
117
|
+
actions_only: bool = False,
|
|
118
|
+
**kwargs,
|
|
119
|
+
):
|
|
120
|
+
super().__init__(**kwargs)
|
|
121
|
+
|
|
122
|
+
self._prepend = FxnOp(prepend_sys)
|
|
123
|
+
self._llm_call = MultipleCompletionLLMCallOp()
|
|
124
|
+
self._action_splitter = FxnOp[Message](lambda x, i: x[i])
|
|
125
|
+
self._dqn_policy = DQNPolicyModule[ToolRequestMessage](
|
|
126
|
+
dqn=dqn, epsilon=epsilon, actions_only=actions_only
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def init_state(self, tools: list[Tool]) -> SimpleAgentState:
|
|
130
|
+
return SimpleAgentState(tools=tools)
|
|
131
|
+
|
|
132
|
+
@compute_graph()
|
|
133
|
+
async def get_asv(
|
|
134
|
+
self, agent_state: SimpleAgentState, obs: list[Message]
|
|
135
|
+
) -> tuple[OpResult[ToolRequestMessage], SimpleAgentState, float]:
|
|
136
|
+
new_state = agent_state.get_next_state(obs)
|
|
137
|
+
|
|
138
|
+
msgs = await self._prepend(new_state.messages, sys_content=self.sys_prompt)
|
|
139
|
+
sampled_actions: OpResult[list[Message]] = await self._llm_call(
|
|
140
|
+
# Override config's n. Also make sure caching=False, since we always want
|
|
141
|
+
# stochastic samples from the LLM for the DQN.
|
|
142
|
+
self.llm_model | {"n": self.num_actions_to_sample, "caching": False},
|
|
143
|
+
msgs=msgs,
|
|
144
|
+
tools=agent_state.tools,
|
|
145
|
+
)
|
|
146
|
+
split_actions: list[OpResult[Message]] = await asyncio.gather(*[
|
|
147
|
+
self._action_splitter(sampled_actions, i)
|
|
148
|
+
for i in range(self.num_actions_to_sample)
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
best_q, best_action = await self._dqn_policy(msgs, *split_actions) # type: ignore[arg-type]
|
|
152
|
+
new_state.messages = [*new_state.messages, best_action.value]
|
|
153
|
+
|
|
154
|
+
return best_action, new_state, best_q
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""This module defines the MemoryAgent class, which extends a base agent model with memory.
|
|
2
|
+
|
|
3
|
+
capabilities. The MemoryAgent can pick and invoke tools based on the stored and retrieved
|
|
4
|
+
memories, formatted using specified prompts. A memory is typically a set of previous trajectories
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from aviary.message import Message
|
|
10
|
+
from aviary.tools import ToolRequestMessage
|
|
11
|
+
from pydantic import ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
from ldp.graph.common_ops import FxnOp, MemoryOp, PromptOp
|
|
14
|
+
from ldp.graph.memory import Memory, MemoryModel
|
|
15
|
+
from ldp.graph.op_utils import compute_graph
|
|
16
|
+
from ldp.graph.ops import OpResult
|
|
17
|
+
from ldp.llms.prompts import indent_xml
|
|
18
|
+
|
|
19
|
+
from .simple_agent import SimpleAgent, SimpleAgentState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryAgent(SimpleAgent):
|
|
23
|
+
"""
|
|
24
|
+
Simple agent that can pick and invoke tools with memory.
|
|
25
|
+
|
|
26
|
+
NOTE: the MemoryAgent does not maintain an explicit value estimate,
|
|
27
|
+
it simply supplies previous trajectories via the prompt.
|
|
28
|
+
As such, the value estimate vhat will always be zero.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
prompt: str = Field(
|
|
32
|
+
default=(
|
|
33
|
+
"<episode-memories>\n<description>\n"
|
|
34
|
+
"These are relevant memories from previous attempts at similar tasks, "
|
|
35
|
+
"along with the action taken and the discounted cumulative reward from that action. "
|
|
36
|
+
"A negative reward is failure, a positive reward is success.\n"
|
|
37
|
+
"</description>{memories}</episode-memories>\n\n"
|
|
38
|
+
"Considering the memories, choose the next action."
|
|
39
|
+
),
|
|
40
|
+
description="Prompt that includes the memories.",
|
|
41
|
+
)
|
|
42
|
+
memory_prompt: str = Field(
|
|
43
|
+
default="<memory><obs>{input}</obs><action>{output}</action><reward>{value}</reward></memory>",
|
|
44
|
+
description="Prompt for formatting an individual memory. "
|
|
45
|
+
"Use XML instead of JSON to avoid potential escaping issues.",
|
|
46
|
+
)
|
|
47
|
+
num_memories: int = Field(
|
|
48
|
+
default=MemoryModel.DEFAULT_MEMORY_MATCHES,
|
|
49
|
+
description="Number of memories to retrieve from MemoryOp",
|
|
50
|
+
)
|
|
51
|
+
# Freeze to ensure the only mutation happens in either the agent state (which is
|
|
52
|
+
# passed around) or in the internal Ops
|
|
53
|
+
model_config = ConfigDict(frozen=True)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _parse_memory(prompt: str, memories: list[Memory]) -> str:
|
|
57
|
+
return indent_xml(
|
|
58
|
+
"\n".join([
|
|
59
|
+
prompt.format(**m.model_dump(exclude={"call_id"})) for m in memories
|
|
60
|
+
])
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _package_messages(
|
|
65
|
+
msgs: list[Message], memory_prompt: str, use_memories: bool
|
|
66
|
+
) -> list[Message]:
|
|
67
|
+
if use_memories:
|
|
68
|
+
return [*msgs, Message(content=memory_prompt)]
|
|
69
|
+
return msgs
|
|
70
|
+
|
|
71
|
+
def __init__(self, memory_model: MemoryModel | None = None, **kwargs):
|
|
72
|
+
super().__init__(**kwargs)
|
|
73
|
+
self._memory_op = MemoryOp(memory_model)
|
|
74
|
+
self._format_memory_op = FxnOp(self._parse_memory)
|
|
75
|
+
self._prompt_op = PromptOp(self.prompt)
|
|
76
|
+
self._package_op = FxnOp(self._package_messages)
|
|
77
|
+
|
|
78
|
+
@compute_graph()
|
|
79
|
+
async def get_asv(
|
|
80
|
+
self, agent_state: SimpleAgentState, obs: list[Message]
|
|
81
|
+
) -> tuple[OpResult[ToolRequestMessage], SimpleAgentState, float]:
|
|
82
|
+
next_state = agent_state.get_next_state(obs)
|
|
83
|
+
|
|
84
|
+
query = "\n\n".join([str(m) for m in next_state.messages if m.role != "system"])
|
|
85
|
+
memories = await self._memory_op(query, matches=self.num_memories)
|
|
86
|
+
packaged_messages = await self._package_op(
|
|
87
|
+
next_state.messages,
|
|
88
|
+
memory_prompt=await self._prompt_op(
|
|
89
|
+
memories=await self._format_memory_op(self.memory_prompt, memories)
|
|
90
|
+
),
|
|
91
|
+
use_memories=bool(memories.value),
|
|
92
|
+
)
|
|
93
|
+
result = cast(
|
|
94
|
+
OpResult[ToolRequestMessage],
|
|
95
|
+
await self._llm_call_op(
|
|
96
|
+
await self._config_op(), msgs=packaged_messages, tools=next_state.tools
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
next_state.messages = [*next_state.messages, result.value]
|
|
100
|
+
return result, next_state, 0.0
|