Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/agentling/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Streaming events emitted by the agent loop.

Memory is the durable, structured record of a run. Events are runtime
notifications emitted as the run progresses, letting callers render output,
inspect tool execution, and react to completion in real time.

StepEvent connects the live event stream to memory by carrying the step that
was just recorded.
"""

from __future__ import annotations

from dataclasses import dataclass

from .memory import Step, ToolResult
from .models import ToolCall, Usage


@dataclass(frozen=True, slots=True)
class TextDelta:
"""A chunk of streamed assistant text."""

text: str


@dataclass(frozen=True, slots=True)
class ToolCallEvent:
"""Emitted before the agent executes a tool call."""

tool_call: ToolCall


@dataclass(frozen=True, slots=True)
class ToolResultEvent:
"""Emitted after a tool call completes, successfully or with an error."""

result: ToolResult


@dataclass(frozen=True, slots=True)
class StepEvent:
"""Emitted after a step has been recorded to memory."""

step: Step


@dataclass(frozen=True, slots=True)
class FinalEvent:
"""Emitted once when the run completes, carrying the answer and total usage."""

answer: str
usage: Usage | None = None


# Public union type for the values yielded by the agent's event stream.
Event = TextDelta | ToolCallEvent | ToolResultEvent | StepEvent | FinalEvent
202 changes: 202 additions & 0 deletions src/agentling/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Typed step records and the agent's memory.

The agent loop records each turn as a typed Step (TaskStep, ActionStep,
FinalStep) rather than as raw messages. Steps carry metadata — token usage,
timing, per-tool results and errors — and know how to render themselves back
into the ChatMessage list the model sees. Memory holds the steps, renders the
full conversation via to_messages(), and round-trips to JSON for persistence
and replay.
"""

from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from typing import Any, ClassVar

from .models import ChatMessage, ToolCall, Usage


@dataclass
class ToolResult:
"""The outcome of a single executed tool call."""

tool_call_id: str
name: str
content: str
is_error: bool = False


@dataclass
class TaskStep:
"""A task that started (or, in multi-turn, continued) the run."""

task: str

def to_messages(self) -> list[ChatMessage]:
"""Render as a single user message."""

return [ChatMessage(role="user", content=self.task)]


@dataclass
class ActionStep:
"""One agent loop iteration.

Stores the assistant message the model produced, any tool results from that
message, and optional execution metadata (error, usage, timing).
"""

model_message: ChatMessage
tool_results: list[ToolResult] = field(default_factory=list)
error: str | None = None
usage: Usage | None = None
duration: float | None = None

def to_messages(self) -> list[ChatMessage]:
"""Render the assistant turn, then one tool message per result."""

# model_message is already role="assistant"; reuse it as-is.
messages: list[ChatMessage] = [self.model_message]

for result in self.tool_results:
content = result.content
# Failed tool calls are returned to the model as observations so the
# next turn can recover. The tool name is included because a single
# step may contain several calls.
if result.is_error:
content = (
f"Error from {result.name!r}: {result.content}. "
"Fix the arguments and try again."
)
messages.append(
ChatMessage(
role="tool", content=content, tool_call_id=result.tool_call_id
)
)

return messages


@dataclass
class FinalStep:
"""The terminal answer produced by the run."""

answer: str

def to_messages(self) -> list[ChatMessage]:
"""Render as the assistant's final message (keeps multi-turn history whole)."""

return [ChatMessage(role="assistant", content=self.answer)]


# A run's history is a sequence of these three step kinds.
Step = TaskStep | ActionStep | FinalStep


@dataclass
class Memory:
"""Ordered history of an agent run.

Stores typed steps rather than raw provider messages, renders them into the
ChatMessage list the model sees, and serializes them for persistence/replay.
"""

# Discriminator tag written into each serialized step so from_dict() can
# rebuild the right class. ClassVar keeps it class-level config, not a field.
_KIND: ClassVar[dict[type, str]] = {
TaskStep: "task",
ActionStep: "action",
FinalStep: "final",
}

steps: list[Step] = field(default_factory=list)

def add(self, step: Step) -> None:
"""Append a step to the run's history."""

self.steps.append(step)

def reset(self) -> None:
"""Clear all steps (used by run(reset=True) to start fresh)."""

self.steps.clear()

def to_messages(self, system_prompt: str) -> list[ChatMessage]:
"""Render the full conversation: system prompt, then every step."""

# The system prompt is runtime configuration, not run history. Injecting
# it here means it is always first and never duplicated in stored steps.
messages: list[ChatMessage] = [
ChatMessage(role="system", content=system_prompt)
]
for step in self.steps:
messages.extend(step.to_messages())
return messages

def to_dict(self) -> dict[str, Any]:
"""Serialize to a JSON-safe dict, tagging each step with its kind."""

# asdict() recurses the whole nested tree (ChatMessage -> ToolCalls ->
# Usage, ToolResults) into plain dicts but drops type information, which
# the load path restores from each step's "type" tag. The version field
# lets the format evolve without breaking older saved runs.
return {
"version": 1,
"steps": [
{"type": self._KIND[type(step)], "data": asdict(step)}
for step in self.steps
],
Comment on lines +144 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

2. Subclass steps break serialization 🐞 Bug ⚙ Maintainability

Memory.to_dict() uses self._KIND[type(step)] for tagging, so any subclass of
TaskStep/ActionStep/FinalStep will raise KeyError and prevent persistence. This makes the step
system unexpectedly non-extensible at runtime.
Agent Prompt
### Issue description
`Memory.to_dict()` tags steps via `self._KIND[type(step)]`, which only works for exact built-in step classes. Subclasses (even compatible ones) won’t be found and will crash serialization with `KeyError`.

### Issue Context
Even if the library doesn’t officially support custom step subclasses today, this behavior is surprising and easy to trip over when users add metadata via subclassing.

### Fix Focus Areas
- src/agentling/memory.py[105-150]

### What to change
- Replace exact-type lookup with a more robust discriminator:
  - Add a `kind: ClassVar[str]` on each step class, or
  - Resolve kind via `next(tag for cls, tag in _KIND.items() if isinstance(step, cls))`, raising a clear `ValueError` if no match.
- Consider adding a small unit test that verifies subclass instances either serialize correctly or fail with a clear error message.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> Memory:
"""Rebuild a Memory from the dict produced by to_dict()."""

return cls(steps=[_step_from_dict(entry) for entry in data.get("steps", [])])

def dump_json(self) -> str:
"""Serialize the memory to a JSON string."""

return json.dumps(self.to_dict())

@classmethod
def load_json(cls, raw: str) -> Memory:
"""Rebuild a Memory from a JSON string produced by dump_json()."""

return cls.from_dict(json.loads(raw))
Comment on lines +152 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Fragile json load path 🐞 Bug ☼ Reliability

Memory.from_dict() iterates over data.get('steps', []) without normalizing/validating, so inputs
like {"steps": null} raise TypeError and malformed entries raise KeyError deep in helpers instead of
a clear ValueError. The serializer emits a version field but the loader ignores it, making format
evolution harder and failures less diagnosable.
Agent Prompt
### Issue description
`Memory.from_dict()` / `_step_from_dict()` / `_chat_message_from_dict()` assume a perfectly-shaped dict matching `to_dict()` output. If `steps` is present but `null` (or not a list), or if nested keys are missing (e.g. `tool_calls`, `tool_call_id`, `tool_results`, `error`, `duration`), loading raises low-level `TypeError`/`KeyError` rather than a controlled `ValueError`. Also, `to_dict()` writes a `version` field but `from_dict()` never checks it.

### Issue Context
This code is intended for persistence/replay. In practice, stored JSON can be manually edited, partially migrated, or come from a newer/older schema version.

### Fix Focus Areas
- src/agentling/memory.py[152-168]
- src/agentling/memory.py[172-200]

### What to change
- In `from_dict`, normalize `steps = data.get("steps") or []` and validate it’s a list of dict entries; raise `ValueError` with a helpful message if invalid.
- Read and validate `version` (e.g. default to 1; raise on unsupported versions).
- In `_chat_message_from_dict` and `_step_from_dict`, use `.get()` with sensible defaults for optional fields (`tool_calls`, `tool_call_id`, `usage`, `tool_results`, `error`, `duration`) and raise `ValueError` on missing required fields (`role`, `content`, etc.) rather than allowing `KeyError`.
- Optionally, accept missing optional keys for backward compatibility (e.g. treat missing `tool_calls` as `[]`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



# dataclasses.asdict() converts nested dataclasses to dicts but does not record
# their types, so the load path rebuilds each nested object explicitly.
def _chat_message_from_dict(d: dict[str, Any]) -> ChatMessage:
"""Reconstruct a ChatMessage (and its nested ToolCalls/Usage) from a dict."""

return ChatMessage(
role=d["role"],
content=d["content"],
tool_calls=[ToolCall(**tc) for tc in d["tool_calls"]],
tool_call_id=d["tool_call_id"],
usage=Usage(**d["usage"]) if d["usage"] else None,
)


def _step_from_dict(entry: dict[str, Any]) -> Step:
"""Reconstruct a Step from a {"type", "data"} entry, dispatching on the tag."""

kind, data = entry["type"], entry["data"]

if kind == "task":
return TaskStep(**data)
if kind == "final":
return FinalStep(**data)
if kind == "action":
return ActionStep(
model_message=_chat_message_from_dict(data["model_message"]),
tool_results=[ToolResult(**tr) for tr in data["tool_results"]],
error=data["error"],
usage=Usage(**data["usage"]) if data["usage"] else None,
duration=data["duration"],
)

raise ValueError(f"Unknown step type: {kind!r}")
Loading
Loading