mirror of
https://github.com/microsoft/autogen.git
synced 2025-10-02 19:47:19 +00:00
Agentchat canvas (#6215)
<!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? This is an initial exploration of what could be a solution for #6214 . It implements a simple text canvas using difflib and also a memory component and a tool component for interacting with the canvas. Still in early testing but would love feedback on the design. ## Related issue number <!-- For example: "Closes #1234" --> ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Leonardo Pinheiro <lpinheiro@microsoft.com> Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
parent
183dbb8dd1
commit
99aac24dd3
@ -62,6 +62,7 @@ python/autogen_ext.tools.graphrag
|
|||||||
python/autogen_ext.tools.http
|
python/autogen_ext.tools.http
|
||||||
python/autogen_ext.tools.langchain
|
python/autogen_ext.tools.langchain
|
||||||
python/autogen_ext.tools.mcp
|
python/autogen_ext.tools.mcp
|
||||||
|
python/autogen_ext.memory.canvas
|
||||||
python/autogen_ext.tools.semantic_kernel
|
python/autogen_ext.tools.semantic_kernel
|
||||||
python/autogen_ext.code_executors.local
|
python/autogen_ext.code_executors.local
|
||||||
python/autogen_ext.code_executors.docker
|
python/autogen_ext.code_executors.docker
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
autogen\_ext.memory.canvas
|
||||||
|
==========================
|
||||||
|
|
||||||
|
|
||||||
|
.. automodule:: autogen_ext.memory.canvas
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -144,6 +144,9 @@ semantic-kernel-all = [
|
|||||||
rich = ["rich>=13.9.4"]
|
rich = ["rich>=13.9.4"]
|
||||||
|
|
||||||
mcp = ["mcp>=1.6.0"]
|
mcp = ["mcp>=1.6.0"]
|
||||||
|
canvas = [
|
||||||
|
"unidiff>=0.7.5",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/autogen_ext"]
|
packages = ["src/autogen_ext"]
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
from ._text_canvas import TextCanvas
|
||||||
|
from ._text_canvas_memory import TextCanvasMemory
|
||||||
|
|
||||||
|
__all__ = ["TextCanvas", "TextCanvasMemory"]
|
@ -0,0 +1,45 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCanvas(ABC):
|
||||||
|
"""
|
||||||
|
An abstract protocol for "canvas" objects that maintain
|
||||||
|
revision history for file-like data. Concrete subclasses
|
||||||
|
can handle text, images, structured data, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_files(self) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Returns a dict of filename -> latest revision number.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_latest_content(self, filename: str) -> Union[str, bytes, Any]:
|
||||||
|
"""
|
||||||
|
Returns the latest version of a file's content.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add_or_update_file(self, filename: str, new_content: Union[str, bytes, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Creates or updates the file content with a new revision.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_diff(self, filename: str, from_revision: int, to_revision: int) -> str:
|
||||||
|
"""
|
||||||
|
Returns a diff (in some format) between two revisions.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def apply_patch(self, filename: str, patch_data: Union[str, bytes, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Applies a patch/diff to the latest revision and increments the revision.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
@ -0,0 +1,64 @@
|
|||||||
|
from autogen_core import CancellationToken
|
||||||
|
from autogen_core.tools import BaseTool
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ._text_canvas import TextCanvas
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFileArgs(BaseModel):
|
||||||
|
filename: str
|
||||||
|
new_content: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFileResult(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFileTool(BaseTool[UpdateFileArgs, UpdateFileResult]):
|
||||||
|
"""
|
||||||
|
Overwrites or creates a file in the canvas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, canvas: TextCanvas):
|
||||||
|
super().__init__(
|
||||||
|
args_type=UpdateFileArgs,
|
||||||
|
return_type=UpdateFileResult,
|
||||||
|
name="update_file",
|
||||||
|
description="Create/update a file on the canvas with the provided content.",
|
||||||
|
)
|
||||||
|
self._canvas = canvas
|
||||||
|
|
||||||
|
async def run(self, args: UpdateFileArgs, cancellation_token: CancellationToken) -> UpdateFileResult:
|
||||||
|
self._canvas.add_or_update_file(args.filename, args.new_content)
|
||||||
|
return UpdateFileResult(status="OK")
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyPatchArgs(BaseModel):
|
||||||
|
filename: str
|
||||||
|
patch_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyPatchResult(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyPatchTool(BaseTool[ApplyPatchArgs, ApplyPatchResult]):
|
||||||
|
"""
|
||||||
|
Applies a unified diff patch to the given file on the canvas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, canvas: TextCanvas):
|
||||||
|
super().__init__(
|
||||||
|
args_type=ApplyPatchArgs,
|
||||||
|
return_type=ApplyPatchResult,
|
||||||
|
name="apply_patch",
|
||||||
|
description=(
|
||||||
|
"Apply a unified diff patch to an existing file on the canvas. "
|
||||||
|
"The patch must be in diff/patch format. The file must exist or be created first."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._canvas = canvas
|
||||||
|
|
||||||
|
async def run(self, args: ApplyPatchArgs, cancellation_token: CancellationToken) -> ApplyPatchResult:
|
||||||
|
self._canvas.apply_patch(args.filename, args.patch_text)
|
||||||
|
return ApplyPatchResult(status="PATCH APPLIED")
|
@ -0,0 +1,188 @@
|
|||||||
|
import difflib
|
||||||
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
|
try: # pragma: no cover
|
||||||
|
from unidiff import PatchSet
|
||||||
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
|
PatchSet = None # type: ignore
|
||||||
|
|
||||||
|
from ._canvas import BaseCanvas
|
||||||
|
|
||||||
|
|
||||||
|
class FileRevision:
|
||||||
|
"""Tracks the history of one file's content."""
|
||||||
|
|
||||||
|
__slots__ = ("content", "revision")
|
||||||
|
|
||||||
|
def __init__(self, content: str, revision: int) -> None:
|
||||||
|
self.content: str = content
|
||||||
|
self.revision: int = revision # e.g. an integer, a timestamp, or git hash
|
||||||
|
|
||||||
|
|
||||||
|
class TextCanvas(BaseCanvas):
|
||||||
|
"""An in‑memory canvas that stores *text* files with full revision history.
|
||||||
|
|
||||||
|
Besides the original CRUD‑like operations, this enhanced implementation adds:
|
||||||
|
|
||||||
|
* **apply_patch** – applies patches using the ``unidiff`` library for accurate
|
||||||
|
hunk application and context line validation.
|
||||||
|
* **get_revision_content** – random access to any historical revision.
|
||||||
|
* **get_revision_diffs** – obtain the list of diffs applied between every
|
||||||
|
consecutive pair of revisions so that a caller can replay or audit the
|
||||||
|
full change history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# Construction helpers
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# For each file we keep an *ordered* list of FileRevision where the last
|
||||||
|
# element is the most recent. Using a list keeps the memory footprint
|
||||||
|
# small and preserves order without any extra bookkeeping.
|
||||||
|
self._files: Dict[str, List[FileRevision]] = {}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# Internal utilities
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _latest_idx(self, filename: str) -> int:
|
||||||
|
"""Return the index (not revision number) of the newest revision."""
|
||||||
|
return len(self._files.get(filename, [])) - 1
|
||||||
|
|
||||||
|
def _ensure_file(self, filename: str) -> None:
|
||||||
|
if filename not in self._files:
|
||||||
|
raise ValueError(f"File '{filename}' does not exist on the canvas; create it first.")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# Revision inspection helpers
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_revision_content(self, filename: str, revision: int) -> str: # NEW 🚀
|
||||||
|
"""Return the exact content stored in *revision*.
|
||||||
|
|
||||||
|
If the revision does not exist an empty string is returned so that
|
||||||
|
downstream code can handle the "not found" case without exceptions.
|
||||||
|
"""
|
||||||
|
for rev in self._files.get(filename, []):
|
||||||
|
if rev.revision == revision:
|
||||||
|
return rev.content
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_revision_diffs(self, filename: str) -> List[str]: # NEW 🚀
|
||||||
|
"""Return a *chronological* list of unified‑diffs for *filename*.
|
||||||
|
|
||||||
|
Each element in the returned list represents the diff that transformed
|
||||||
|
revision *n* into revision *n+1* (starting at revision 1 → 2).
|
||||||
|
"""
|
||||||
|
revisions = self._files.get(filename, [])
|
||||||
|
diffs: List[str] = []
|
||||||
|
for i in range(1, len(revisions)):
|
||||||
|
older, newer = revisions[i - 1], revisions[i]
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
older.content.splitlines(keepends=True),
|
||||||
|
newer.content.splitlines(keepends=True),
|
||||||
|
fromfile=f"{filename}@r{older.revision}",
|
||||||
|
tofile=f"{filename}@r{newer.revision}",
|
||||||
|
)
|
||||||
|
diffs.append("".join(diff))
|
||||||
|
return diffs
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# BaseCanvas interface implementation
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_files(self) -> Dict[str, int]:
|
||||||
|
"""Return a mapping of *filename → latest revision number*."""
|
||||||
|
return {fname: revs[-1].revision for fname, revs in self._files.items() if revs}
|
||||||
|
|
||||||
|
def get_latest_content(self, filename: str) -> str: # noqa: D401 – keep API identical
|
||||||
|
"""Return the most recent content or an empty string if the file is new."""
|
||||||
|
revs = self._files.get(filename, [])
|
||||||
|
return revs[-1].content if revs else ""
|
||||||
|
|
||||||
|
def add_or_update_file(self, filename: str, new_content: Union[str, bytes, Any]) -> None:
|
||||||
|
"""Create *filename* or append a new revision containing *new_content*."""
|
||||||
|
if isinstance(new_content, bytes):
|
||||||
|
new_content = new_content.decode("utf-8")
|
||||||
|
if not isinstance(new_content, str):
|
||||||
|
raise ValueError(f"Expected str or bytes, got {type(new_content)}")
|
||||||
|
if filename not in self._files:
|
||||||
|
self._files[filename] = [FileRevision(new_content, 1)]
|
||||||
|
else:
|
||||||
|
last_rev_num = self._files[filename][-1].revision
|
||||||
|
self._files[filename].append(FileRevision(new_content, last_rev_num + 1))
|
||||||
|
|
||||||
|
def get_diff(self, filename: str, from_revision: int, to_revision: int) -> str:
|
||||||
|
"""Return a unified diff between *from_revision* and *to_revision*."""
|
||||||
|
revisions = self._files.get(filename, [])
|
||||||
|
if not revisions:
|
||||||
|
return ""
|
||||||
|
# Fetch the contents for the requested revisions.
|
||||||
|
from_content = self.get_revision_content(filename, from_revision)
|
||||||
|
to_content = self.get_revision_content(filename, to_revision)
|
||||||
|
if from_content == "" and to_content == "": # one (or both) revision ids not found
|
||||||
|
return ""
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
from_content.splitlines(keepends=True),
|
||||||
|
to_content.splitlines(keepends=True),
|
||||||
|
fromfile=f"{filename}@r{from_revision}",
|
||||||
|
tofile=f"{filename}@r{to_revision}",
|
||||||
|
)
|
||||||
|
return "".join(diff)
|
||||||
|
|
||||||
|
def apply_patch(self, filename: str, patch_data: Union[str, bytes, Any]) -> None:
|
||||||
|
"""Apply *patch_text* (unified diff) to the latest revision and save a new revision.
|
||||||
|
|
||||||
|
Uses the *unidiff* library to accurately apply hunks and validate context lines.
|
||||||
|
"""
|
||||||
|
if isinstance(patch_data, bytes):
|
||||||
|
patch_data = patch_data.decode("utf-8")
|
||||||
|
if not isinstance(patch_data, str):
|
||||||
|
raise ValueError(f"Expected str or bytes, got {type(patch_data)}")
|
||||||
|
self._ensure_file(filename)
|
||||||
|
original_content = self.get_latest_content(filename)
|
||||||
|
|
||||||
|
if PatchSet is None:
|
||||||
|
raise ImportError(
|
||||||
|
"The 'unidiff' package is required for patch application. Install with 'pip install unidiff'."
|
||||||
|
)
|
||||||
|
|
||||||
|
patch = PatchSet(patch_data)
|
||||||
|
# Our canvas stores exactly one file per patch operation so we
|
||||||
|
# use the first (and only) patched_file object.
|
||||||
|
if not patch:
|
||||||
|
raise ValueError("Empty patch text provided.")
|
||||||
|
patched_file = patch[0]
|
||||||
|
working_lines = original_content.splitlines(keepends=True)
|
||||||
|
line_offset = 0
|
||||||
|
for hunk in patched_file:
|
||||||
|
# Calculate the slice boundaries in the *current* working copy.
|
||||||
|
start = hunk.source_start - 1 + line_offset
|
||||||
|
end = start + hunk.source_length
|
||||||
|
# Build the replacement block for this hunk.
|
||||||
|
replacement: List[str] = []
|
||||||
|
for line in hunk:
|
||||||
|
if line.is_added or line.is_context:
|
||||||
|
replacement.append(line.value)
|
||||||
|
# removed lines (line.is_removed) are *not* added.
|
||||||
|
# Replace the slice with the hunk‑result.
|
||||||
|
working_lines[start:end] = replacement
|
||||||
|
line_offset += len(replacement) - (end - start)
|
||||||
|
new_content = "".join(working_lines)
|
||||||
|
|
||||||
|
# Finally commit the new revision.
|
||||||
|
self.add_or_update_file(filename, new_content)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# Convenience helpers
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_all_contents_for_context(self) -> str: # noqa: D401 – keep public API stable
|
||||||
|
"""Return a summarised view of every file and its *latest* revision."""
|
||||||
|
out: List[str] = ["=== CANVAS FILES ==="]
|
||||||
|
for fname, revs in self._files.items():
|
||||||
|
latest = revs[-1]
|
||||||
|
out.append(f"File: {fname} (rev {latest.revision}):\n{latest.content}\n")
|
||||||
|
out.append("=== END OF CANVAS ===")
|
||||||
|
return "\n".join(out)
|
@ -0,0 +1,225 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from autogen_core import CancellationToken
|
||||||
|
from autogen_core.memory import (
|
||||||
|
Memory,
|
||||||
|
MemoryContent,
|
||||||
|
MemoryMimeType,
|
||||||
|
MemoryQueryResult,
|
||||||
|
UpdateContextResult,
|
||||||
|
)
|
||||||
|
from autogen_core.model_context import ChatCompletionContext
|
||||||
|
from autogen_core.models import SystemMessage
|
||||||
|
|
||||||
|
from ._canvas_writer import ApplyPatchTool, UpdateFileTool
|
||||||
|
from ._text_canvas import TextCanvas
|
||||||
|
|
||||||
|
|
||||||
|
class TextCanvasMemory(Memory):
|
||||||
|
"""
|
||||||
|
A memory implementation that uses a Canvas for storing file-like content.
|
||||||
|
Inserts the current state of the canvas into the ChatCompletionContext on each turn.
|
||||||
|
|
||||||
|
The TextCanvasMemory provides a persistent, file-like storage mechanism that can be used
|
||||||
|
by agents to read and write content. It automatically injects the current state of all files
|
||||||
|
in the canvas into the model context before each inference.
|
||||||
|
|
||||||
|
This is particularly useful for:
|
||||||
|
- Allowing agents to create and modify documents over multiple turns
|
||||||
|
- Enabling collaborative document editing between multiple agents
|
||||||
|
- Maintaining persistent state across conversation turns
|
||||||
|
- Working with content too large to fit in a single message
|
||||||
|
|
||||||
|
The canvas provides tools for:
|
||||||
|
- Creating or updating files with new content
|
||||||
|
- Applying patches (unified diff format) to existing files
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
**Example: Using TextCanvasMemory with an AssistantAgent**
|
||||||
|
|
||||||
|
The following example demonstrates how to create a TextCanvasMemory and use it with
|
||||||
|
an AssistantAgent to write and update a story file.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from autogen_core import CancellationToken
|
||||||
|
from autogen_ext.models.openai import OpenAIChatCompletionClient
|
||||||
|
from autogen_agentchat.agents import AssistantAgent
|
||||||
|
from autogen_agentchat.messages import TextMessage
|
||||||
|
from autogen_ext.memory.canvas import TextCanvasMemory
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Create a model client
|
||||||
|
model_client = OpenAIChatCompletionClient(
|
||||||
|
model="gpt-4o",
|
||||||
|
# api_key = "your_openai_api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the canvas memory
|
||||||
|
text_canvas_memory = TextCanvasMemory()
|
||||||
|
|
||||||
|
# Get tools for working with the canvas
|
||||||
|
update_file_tool = text_canvas_memory.get_update_file_tool()
|
||||||
|
apply_patch_tool = text_canvas_memory.get_apply_patch_tool()
|
||||||
|
|
||||||
|
# Create an agent with the canvas memory and tools
|
||||||
|
writer_agent = AssistantAgent(
|
||||||
|
name="Writer",
|
||||||
|
model_client=model_client,
|
||||||
|
description="A writer agent that creates and updates stories.",
|
||||||
|
system_message='''
|
||||||
|
You are a Writer Agent. Your focus is to generate a story based on the user's request.
|
||||||
|
|
||||||
|
Instructions for using the canvas:
|
||||||
|
|
||||||
|
- The story should be stored on the canvas in a file named "story.md".
|
||||||
|
- If "story.md" does not exist, create it by calling the 'update_file' tool.
|
||||||
|
- If "story.md" already exists, generate a unified diff (patch) from the current
|
||||||
|
content to the new version, and call the 'apply_patch' tool to apply the changes.
|
||||||
|
|
||||||
|
IMPORTANT: Do not include the full story text in your chat messages.
|
||||||
|
Only write the story content to the canvas using the tools.
|
||||||
|
''',
|
||||||
|
tools=[update_file_tool, apply_patch_tool],
|
||||||
|
memory=[text_canvas_memory],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send a message to the agent
|
||||||
|
await writer_agent.on_messages(
|
||||||
|
[TextMessage(content="Write a short story about a bunny and a sunflower.", source="user")],
|
||||||
|
CancellationToken(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve the content from the canvas
|
||||||
|
story_content = text_canvas_memory.canvas.get_latest_content("story.md")
|
||||||
|
print("Story content from canvas:")
|
||||||
|
print(story_content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
**Example: Using TextCanvasMemory with multiple agents**
|
||||||
|
|
||||||
|
The following example shows how to use TextCanvasMemory with multiple agents
|
||||||
|
collaborating on the same document.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from autogen_ext.models.openai import OpenAIChatCompletionClient
|
||||||
|
from autogen_agentchat.agents import AssistantAgent
|
||||||
|
from autogen_agentchat.teams import RoundRobinGroupChat
|
||||||
|
from autogen_agentchat.conditions import TextMentionTermination
|
||||||
|
from autogen_ext.memory.canvas import TextCanvasMemory
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Create a model client
|
||||||
|
model_client = OpenAIChatCompletionClient(
|
||||||
|
model="gpt-4o",
|
||||||
|
# api_key = "your_openai_api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the shared canvas memory
|
||||||
|
text_canvas_memory = TextCanvasMemory()
|
||||||
|
update_file_tool = text_canvas_memory.get_update_file_tool()
|
||||||
|
apply_patch_tool = text_canvas_memory.get_apply_patch_tool()
|
||||||
|
|
||||||
|
# Create a writer agent
|
||||||
|
writer_agent = AssistantAgent(
|
||||||
|
name="Writer",
|
||||||
|
model_client=model_client,
|
||||||
|
description="A writer agent that creates stories.",
|
||||||
|
system_message="You write children's stories on the canvas in story.md.",
|
||||||
|
tools=[update_file_tool, apply_patch_tool],
|
||||||
|
memory=[text_canvas_memory],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a critique agent
|
||||||
|
critique_agent = AssistantAgent(
|
||||||
|
name="Critique",
|
||||||
|
model_client=model_client,
|
||||||
|
description="A critique agent that provides feedback on stories.",
|
||||||
|
system_message="You review the story.md file and provide constructive feedback.",
|
||||||
|
memory=[text_canvas_memory],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a team with both agents
|
||||||
|
team = RoundRobinGroupChat(
|
||||||
|
participants=[writer_agent, critique_agent],
|
||||||
|
termination_condition=TextMentionTermination("TERMINATE"),
|
||||||
|
max_turns=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the team on a task
|
||||||
|
await team.run(task="Create a children's book about a bunny and a sunflower")
|
||||||
|
|
||||||
|
# Get the final story
|
||||||
|
story = text_canvas_memory.canvas.get_latest_content("story.md")
|
||||||
|
print(story)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, canvas: Optional[TextCanvas] = None):
|
||||||
|
super().__init__()
|
||||||
|
self.canvas = canvas if canvas is not None else TextCanvas()
|
||||||
|
|
||||||
|
async def update_context(self, model_context: ChatCompletionContext) -> UpdateContextResult:
|
||||||
|
"""
|
||||||
|
Inject the entire canvas summary (or a selected subset) as reference data.
|
||||||
|
Here, we just put it into a system message, but you could customize.
|
||||||
|
"""
|
||||||
|
snapshot = self.canvas.get_all_contents_for_context()
|
||||||
|
if snapshot.strip():
|
||||||
|
msg = SystemMessage(content=snapshot)
|
||||||
|
await model_context.add_message(msg)
|
||||||
|
|
||||||
|
# Return it for debugging/logging
|
||||||
|
memory_content = MemoryContent(content=snapshot, mime_type=MemoryMimeType.TEXT)
|
||||||
|
return UpdateContextResult(memories=MemoryQueryResult(results=[memory_content]))
|
||||||
|
|
||||||
|
return UpdateContextResult(memories=MemoryQueryResult(results=[]))
|
||||||
|
|
||||||
|
async def query(
|
||||||
|
self, query: str | MemoryContent, cancellation_token: Optional[CancellationToken] = None, **kwargs: Any
|
||||||
|
) -> MemoryQueryResult:
|
||||||
|
"""
|
||||||
|
Potentially search for matching filenames or file content.
|
||||||
|
This example returns empty.
|
||||||
|
"""
|
||||||
|
return MemoryQueryResult(results=[])
|
||||||
|
|
||||||
|
async def add(self, content: MemoryContent, cancellation_token: Optional[CancellationToken] = None) -> None:
|
||||||
|
"""
|
||||||
|
Example usage: Possibly interpret content as a patch or direct file update.
|
||||||
|
Could also be done by a specialized "CanvasTool" instead.
|
||||||
|
"""
|
||||||
|
# NO-OP here, leaving actual changes to the CanvasTool
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def clear(self) -> None:
|
||||||
|
"""Clear the entire canvas by replacing it with a new empty instance."""
|
||||||
|
# Create a new TextCanvas instance instead of calling __init__ directly
|
||||||
|
self.canvas = TextCanvas()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_update_file_tool(self) -> UpdateFileTool:
|
||||||
|
"""
|
||||||
|
Returns an UpdateFileTool instance that works with this memory's canvas.
|
||||||
|
"""
|
||||||
|
return UpdateFileTool(self.canvas)
|
||||||
|
|
||||||
|
def get_apply_patch_tool(self) -> ApplyPatchTool:
|
||||||
|
"""
|
||||||
|
Returns an ApplyPatchTool instance that works with this memory's canvas.
|
||||||
|
"""
|
||||||
|
return ApplyPatchTool(self.canvas)
|
@ -0,0 +1,121 @@
|
|||||||
|
import difflib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from autogen_core import CancellationToken
|
||||||
|
from autogen_core.model_context import UnboundedChatCompletionContext
|
||||||
|
from autogen_ext.memory.canvas import TextCanvasMemory
|
||||||
|
from autogen_ext.memory.canvas._canvas_writer import (
|
||||||
|
ApplyPatchArgs,
|
||||||
|
UpdateFileArgs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ─────────────────────────────────────────────────────────────────────
|
||||||
|
@pytest.fixture()
|
||||||
|
def story_v1() -> str:
|
||||||
|
# Extracted (slightly trimmed) from the sample output
|
||||||
|
return (
|
||||||
|
"# The Bunny and the Sunflower\n\n"
|
||||||
|
"## Beginning\n"
|
||||||
|
"Once upon a time, in a bright and cheerful meadow, Bella the bunny came "
|
||||||
|
"across **a beautiful sunflower** waving in the sunshine.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def story_v2(story_v1: str) -> str:
|
||||||
|
# A small edit: give the sunflower a name (mirrors the first patch in the log)
|
||||||
|
return story_v1.replace(
|
||||||
|
"a beautiful sunflower",
|
||||||
|
"a beautiful sunflower named Sunny",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def memory() -> TextCanvasMemory:
|
||||||
|
return TextCanvasMemory()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────────────────
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_canvas_initial_state(memory: TextCanvasMemory) -> None:
|
||||||
|
assert memory.canvas.list_files() == {}
|
||||||
|
snapshot = memory.canvas.get_all_contents_for_context()
|
||||||
|
assert snapshot.startswith("=== CANVAS FILES ===")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_file_tool_creates_file(
|
||||||
|
memory: TextCanvasMemory,
|
||||||
|
story_v1: str,
|
||||||
|
) -> None:
|
||||||
|
update_tool = memory.get_update_file_tool()
|
||||||
|
|
||||||
|
await update_tool.run(
|
||||||
|
UpdateFileArgs(filename="story.md", new_content=story_v1),
|
||||||
|
CancellationToken(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert memory.canvas.get_latest_content("story.md") == story_v1
|
||||||
|
assert memory.canvas.list_files()["story.md"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_patch_increments_revision(
|
||||||
|
memory: TextCanvasMemory,
|
||||||
|
story_v1: str,
|
||||||
|
story_v2: str,
|
||||||
|
) -> None:
|
||||||
|
# Set up revision 1
|
||||||
|
await memory.get_update_file_tool().run(
|
||||||
|
UpdateFileArgs(filename="story.md", new_content=story_v1),
|
||||||
|
CancellationToken(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a unified diff for the patch tool
|
||||||
|
diff_text = "".join(
|
||||||
|
difflib.unified_diff(
|
||||||
|
story_v1.splitlines(keepends=True),
|
||||||
|
story_v2.splitlines(keepends=True),
|
||||||
|
fromfile="story.md",
|
||||||
|
tofile="story.md",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the patch → revision 2
|
||||||
|
await memory.get_apply_patch_tool().run(
|
||||||
|
ApplyPatchArgs(filename="story.md", patch_text=diff_text),
|
||||||
|
CancellationToken(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert memory.canvas.get_latest_content("story.md") == story_v2
|
||||||
|
# The revision number should now be 2
|
||||||
|
assert memory.canvas.list_files()["story.md"] == 2
|
||||||
|
# And the diff history should contain exactly one patch
|
||||||
|
assert len(memory.canvas.get_revision_diffs("story.md")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_context_injects_snapshot(
|
||||||
|
memory: TextCanvasMemory,
|
||||||
|
story_v2: str,
|
||||||
|
) -> None:
|
||||||
|
# Seed with some content
|
||||||
|
await memory.get_update_file_tool().run(
|
||||||
|
UpdateFileArgs(filename="story.md", new_content=story_v2),
|
||||||
|
CancellationToken(),
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_ctx = UnboundedChatCompletionContext()
|
||||||
|
result = await memory.update_context(chat_ctx)
|
||||||
|
|
||||||
|
# A single SystemMessage should have been added to the context
|
||||||
|
assert len(chat_ctx._messages) == 1 # type: ignore
|
||||||
|
injected_text = chat_ctx._messages[0].content # type: ignore
|
||||||
|
assert "=== CANVAS FILES ===" in injected_text
|
||||||
|
assert "story.md" in injected_text
|
||||||
|
|
||||||
|
# The UpdateContextResult should surface the same snapshot via MemoryContent
|
||||||
|
assert result.memories.results
|
||||||
|
assert isinstance(result.memories.results[0].content, str)
|
||||||
|
assert story_v2.strip() in result.memories.results[0].content
|
13
python/uv.lock
generated
13
python/uv.lock
generated
@ -601,6 +601,9 @@ azure = [
|
|||||||
{ name = "azure-identity" },
|
{ name = "azure-identity" },
|
||||||
{ name = "azure-search-documents" },
|
{ name = "azure-search-documents" },
|
||||||
]
|
]
|
||||||
|
canvas = [
|
||||||
|
{ name = "unidiff" },
|
||||||
|
]
|
||||||
chromadb = [
|
chromadb = [
|
||||||
{ name = "chromadb" },
|
{ name = "chromadb" },
|
||||||
]
|
]
|
||||||
@ -792,6 +795,7 @@ requires-dist = [
|
|||||||
{ name = "semantic-kernel", extras = ["pandas"], marker = "extra == 'semantic-kernel-pandas'", specifier = ">=1.17.1" },
|
{ name = "semantic-kernel", extras = ["pandas"], marker = "extra == 'semantic-kernel-pandas'", specifier = ">=1.17.1" },
|
||||||
{ name = "tiktoken", marker = "extra == 'ollama'", specifier = ">=0.8.0" },
|
{ name = "tiktoken", marker = "extra == 'ollama'", specifier = ">=0.8.0" },
|
||||||
{ name = "tiktoken", marker = "extra == 'openai'", specifier = ">=0.8.0" },
|
{ name = "tiktoken", marker = "extra == 'openai'", specifier = ">=0.8.0" },
|
||||||
|
{ name = "unidiff", marker = "extra == 'canvas'", specifier = ">=0.7.5" },
|
||||||
{ name = "websockets", marker = "extra == 'docker-jupyter-executor'", specifier = ">=15.0.1" },
|
{ name = "websockets", marker = "extra == 'docker-jupyter-executor'", specifier = ">=15.0.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -7966,6 +7970,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/8f/671c0e1f2572ba625cbcc1faeba9435e00330c3d6962858711445cf1e817/umap_learn-0.5.7-py3-none-any.whl", hash = "sha256:6a7e0be2facfa365a5ed6588447102bdbef32a0ef449535c25c97ea7e680073c", size = 88815 },
|
{ url = "https://files.pythonhosted.org/packages/3c/8f/671c0e1f2572ba625cbcc1faeba9435e00330c3d6962858711445cf1e817/umap_learn-0.5.7-py3-none-any.whl", hash = "sha256:6a7e0be2facfa365a5ed6588447102bdbef32a0ef449535c25c97ea7e680073c", size = 88815 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unidiff"
|
||||||
|
version = "0.7.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uptrace"
|
name = "uptrace"
|
||||||
version = "1.29.0"
|
version = "1.29.0"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user