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.
Files changed (51) hide show
  1. ldp/__init__.py +0 -0
  2. ldp/agent/__init__.py +33 -0
  3. ldp/agent/agent.py +168 -0
  4. ldp/agent/agent_client.py +147 -0
  5. ldp/agent/dqn_agent.py +154 -0
  6. ldp/agent/memory_agent.py +100 -0
  7. ldp/agent/react_agent.py +140 -0
  8. ldp/agent/simple_agent.py +102 -0
  9. ldp/agent/tree_of_thoughts_agent.py +142 -0
  10. ldp/alg/__init__.py +0 -0
  11. ldp/alg/algorithms.py +167 -0
  12. ldp/alg/beam_search.py +218 -0
  13. ldp/alg/callbacks.py +377 -0
  14. ldp/alg/datasets.py +11 -0
  15. ldp/alg/optimizer/__init__.py +96 -0
  16. ldp/alg/optimizer/ape.py +297 -0
  17. ldp/alg/optimizer/dqn.py +492 -0
  18. ldp/alg/optimizer/memory.py +108 -0
  19. ldp/alg/optimizer/openai_sft_optimizer.py +309 -0
  20. ldp/alg/optimizer/opt.py +50 -0
  21. ldp/alg/optimizer/replay_buffers.py +34 -0
  22. ldp/alg/rollout.py +309 -0
  23. ldp/alg/runners.py +313 -0
  24. ldp/alg/tree_search.py +149 -0
  25. ldp/data_structures.py +112 -0
  26. ldp/graph/__init__.py +0 -0
  27. ldp/graph/async_torch.py +222 -0
  28. ldp/graph/common_ops.py +370 -0
  29. ldp/graph/gradient_estimators.py +144 -0
  30. ldp/graph/memory.py +146 -0
  31. ldp/graph/modules/__init__.py +31 -0
  32. ldp/graph/modules/llm_call.py +30 -0
  33. ldp/graph/modules/react.py +272 -0
  34. ldp/graph/modules/reflect.py +59 -0
  35. ldp/graph/modules/thought.py +44 -0
  36. ldp/graph/modules/value_function.py +366 -0
  37. ldp/graph/op_utils.py +148 -0
  38. ldp/graph/ops.py +513 -0
  39. ldp/graph/pydantic_patch.py +50 -0
  40. ldp/graph/torch_ops.py +137 -0
  41. ldp/llms/__init__.py +41 -0
  42. ldp/llms/chat.py +351 -0
  43. ldp/llms/embeddings.py +134 -0
  44. ldp/llms/prompts.py +100 -0
  45. ldp/py.typed +0 -0
  46. ldp/version.py +16 -0
  47. ldp-0.1.0.dist-info/LICENSE +201 -0
  48. ldp-0.1.0.dist-info/METADATA +138 -0
  49. ldp-0.1.0.dist-info/RECORD +51 -0
  50. ldp-0.1.0.dist-info/WHEEL +5 -0
  51. 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