feat(memory): add tool call result handling and memory management

- Add new ToolCallResult and ToolMemory schemas for tracking tool executions
- Implement tool memory retrieval and summarization flows in default config
- Register new parse_tool_call_result_op for processing tool call results
- Extend memory conversion logic to support tool memory type
- Add test cases for tool memory serialization and deserialization
- Include token counting utilities for text processing tasks
This commit is contained in:
jinli.yl 2025-10-16 10:50:27 +08:00
parent 92f76566db
commit b9da8d2a7f
10 changed files with 280 additions and 15 deletions

View file

@ -49,13 +49,31 @@ flow:
description: "A list of conversation trajectory information, including message content and score. This field does not need to be filled in, the system will complete it automatically."
required: false
retrieve_task_memory_simple:
flow_content: build_query_op >> recall_vector_store_op >> merge_memory_op
retrieve_tool_memory:
flow_content: parse_tool_call_result_op
description: "Retrieve tool related memories from the memory database based on queries to improve tool recall"
input_schema:
tool_names:
type: string
description: "tool_name1,tool_name2"
required: true
add_tool_call_result:
flow_content: parse_tool_call_result_op
description: "Retrieves the most relevant top-k memory experiences from historical data based on the current query with simplified processing"
input_schema:
query:
tool_call_results:
type: array
description: "tool_call_result"
required: true
summary_tool_call_result:
flow_content: parse_tool_call_result_op
description: "Retrieves the most relevant top-k memory experiences from historical data based on the current query with simplified processing"
input_schema:
tool_names:
type: string
description: "current query"
description: "tool_name1,tool_name2"
required: true
summary_task_memory_simple:
@ -143,6 +161,12 @@ llm:
params:
temperature: 0.6
wk1:
backend: openai_compatible
model_name: qwen3-30b-a3b-instruct-2507
params:
temperature: 0.6
qwen3_30b_instruct:
backend: openai_compatible
model_name: qwen3-30b-a3b-instruct-2507

View file

@ -1,2 +1,3 @@
from . import personal
from . import task
from . import tool

View file

View file

@ -1,8 +1,10 @@
import datetime
from abc import ABC
from typing import List
from uuid import uuid4
from flowllm.schema.vector_node import VectorNode
from mcp.types import CallToolResult, TextContent
from pydantic import BaseModel, Field
@ -105,6 +107,118 @@ class PersonalMemory(BaseMemory):
metadata=metadata.pop("metadata", {}))
class ToolCallResult(BaseModel):
create_time: str = Field(default="", description="Time of tool invocation")
tool_name: str = Field(default=..., description="Name of the tool")
input: dict | str = Field(default="", description="Tool input")
output: str = Field(default="", description="Tool output")
token_cost: int = Field(default=-1, description="Token consumption of the tool")
success: bool = Field(default=True, description="Whether the tool invocation was successful")
time_cost: float = Field(default=0, description="Time consumed by the tool invocation, in seconds")
evaluation: str = Field(default="", description="Evaluation for the tool invocation")
score: float = Field(default=0, description="Score of the Evaluation")
# updated: bool = Field(default=False, description="Whether tool memory has been updated by `ToolCallMetadata`")
metadata: dict = Field(default_factory=dict)
def from_mcp_tool_result(self, tool_result: CallToolResult, max_char_len: int = None):
text_list = []
for content in tool_result.content:
if isinstance(content, TextContent):
text_list.append(content.text)
else:
raise NotImplementedError(f"content.type={type(content)} not supported")
content = "\n".join(text_list)
if max_char_len:
content = content[:max_char_len]
self.output = content
self.success = tool_result.is_error
self.metadata.update(tool_result.meta)
class ToolMemory(BaseMemory):
memory_type: str = Field(default="tool")
tool_call_results: List[ToolCallResult] = Field(default_factory=list)
def to_vector_node(self) -> VectorNode:
return VectorNode(unique_id=self.memory_id,
workspace_id=self.workspace_id,
content=self.when_to_use,
metadata={
"memory_type": self.memory_type,
"content": self.content,
"score": self.score,
"time_created": self.time_created,
"time_modified": self.time_modified,
"author": self.author,
"tool_call_results": [x.model_dump() for x in self.tool_call_results],
"metadata": self.metadata,
})
def statistic(self, recent_frequency: int = 20) -> dict:
"""
Calculate statistical information for the most recent 20 tool calls.
Returns avg token_cost, success rate, avg time_cost, and avg score.
"""
if not self.tool_call_results:
return {
"total_calls": 0,
"recent_calls_analyzed": 0,
"avg_token_cost": 0.0,
"success_rate": 0.0,
"avg_time_cost": 0.0,
"avg_score": 0.0
}
# Get the most recent 20 tool calls (or all if less than 20)
recent_calls = self.tool_call_results[-20:]
total_calls = len(self.tool_call_results)
recent_calls_count = len(recent_calls)
# Calculate statistics
total_token_cost = sum(call.token_cost for call in recent_calls if call.token_cost >= 0)
valid_token_calls = [call for call in recent_calls if call.token_cost >= 0]
avg_token_cost = total_token_cost / len(valid_token_calls) if valid_token_calls else 0.0
successful_calls = sum(1 for call in recent_calls if call.success)
success_rate = successful_calls / recent_calls_count if recent_calls_count > 0 else 0.0
total_time_cost = sum(call.time_cost for call in recent_calls)
avg_time_cost = total_time_cost / recent_calls_count if recent_calls_count > 0 else 0.0
total_score = sum(call.score for call in recent_calls)
avg_score = total_score / recent_calls_count if recent_calls_count > 0 else 0.0
return {
"total_calls": total_calls,
"recent_calls_analyzed": recent_calls_count,
"avg_token_cost": round(avg_token_cost, 2),
"success_rate": round(success_rate, 4),
"avg_time_cost": round(avg_time_cost, 3),
"avg_score": round(avg_score, 3)
}
@classmethod
def from_vector_node(cls, node: VectorNode) -> "ToolMemory":
metadata = node.metadata.copy()
tool_call_results = [ToolCallResult(**result) for result in metadata.pop("tool_call_results", [])]
return cls(workspace_id=node.workspace_id,
memory_id=node.unique_id,
when_to_use=node.content,
memory_type=metadata.pop("memory_type"),
content=metadata.pop("content"),
score=metadata.pop("score"),
time_created=metadata.pop("time_created"),
time_modified=metadata.pop("time_modified"),
author=metadata.pop("author"),
tool_call_results=tool_call_results,
metadata=metadata.pop("metadata", {}))
def vector_node_to_memory(node: VectorNode) -> BaseMemory:
memory_type = node.metadata.get("memory_type")
if memory_type == "task":
@ -113,6 +227,9 @@ def vector_node_to_memory(node: VectorNode) -> BaseMemory:
elif memory_type == "personal":
return PersonalMemory.from_vector_node(node)
elif memory_type == "tool":
return ToolMemory.from_vector_node(node)
else:
raise RuntimeError(f"memory_type={memory_type} not supported!")
@ -125,11 +242,14 @@ def dict_to_memory(memory_dict: dict):
elif memory_type == "personal":
return PersonalMemory(**memory_dict)
elif memory_type == "tool":
return ToolMemory(**memory_dict)
else:
raise RuntimeError(f"memory_type={memory_type} not supported!")
if __name__ == "__main__":
def task_main():
e1 = TaskMemory(
workspace_id="w_1024",
memory_id="123",
@ -142,3 +262,78 @@ if __name__ == "__main__":
print(v1.model_dump_json(indent=2))
e2 = vector_node_to_memory(v1)
print(e2.model_dump_json(indent=2))
def personal_main():
p1 = PersonalMemory(
workspace_id="w_2048",
memory_id="456",
when_to_use="personal memory test case",
content="personal test content",
target="user_preferences",
reflection_subject="learning_style",
score=0.85,
metadata={"category": "user_profile"})
print("PersonalMemory test:")
print(p1.model_dump_json(indent=2))
v1 = p1.to_vector_node()
print("VectorNode:")
print(v1.model_dump_json(indent=2))
p2 = vector_node_to_memory(v1)
print("Reconstructed PersonalMemory:")
print(p2.model_dump_json(indent=2))
def tool_main():
# Create sample tool call results
tool_result1 = ToolCallResult(
create_time="2025-10-15 10:30:00",
tool_name="file_reader",
input={"file_path": "/test/file.txt"},
output="File content successfully read",
token_cost=50,
success=True,
time_cost=0.5,
evaluation="Successfully executed",
score=0.95
)
tool_result2 = ToolCallResult(
create_time="2025-10-15 10:31:00",
tool_name="data_processor",
input={"data": "sample_data", "format": "json"},
output="Data processed successfully",
token_cost=75,
success=True,
time_cost=1.2,
evaluation="Good performance",
score=0.88
)
t1 = ToolMemory(
workspace_id="w_4096",
memory_id="789",
memory_type="tool",
when_to_use="tool execution memory test",
content="tool execution test content",
score=0.92,
tool_call_results=[tool_result1, tool_result2],
metadata={"execution_context": "test_environment"})
print("ToolMemory test:")
print(t1.model_dump_json(indent=2))
v1 = t1.to_vector_node()
print("VectorNode:")
print(v1.model_dump_json(indent=2))
t2 = ToolMemory.from_vector_node(v1)
print("Reconstructed ToolMemory:")
print(t2.model_dump_json(indent=2))
if __name__ == "__main__":
print("=== Task Memory Test ===")
# task_main()
print("\n=== Personal Memory Test ===")
# personal_main()
print("\n=== Tool Memory Test ===")
tool_main()

View file

@ -1,2 +1,3 @@
from . import personal
from . import task
from . import tool

View file

@ -0,0 +1 @@
from .parse_tool_call_result_op import ParseToolCallResultOp

View file

@ -0,0 +1,13 @@
from flowllm import C, BaseAsyncOp
from loguru import logger
@C.register_op()
class ParseToolCallResultOp(BaseAsyncOp):
file_path: str = __file__
async def async_execute(self):
tool_call_results: list = self.context.get("tool_call_results", [])
logger.info(f"tool_call_results={tool_call_results}")
self.context.response.answer = "haha"

View file

@ -110,23 +110,34 @@ async def run2(session):
result = await response.json()
print(json.dumps(result, ensure_ascii=False))
async def run3(session):
workspace_id = "default2"
async with session.post(
f"{base_url}/add_tool_call_result",
json={
"tool_call_results": [
{"a": 1},
{"a": 2},
],
"workspace_id": workspace_id,
},
headers={"Content-Type": "application/json"}
) as response:
result = await response.json()
print(json.dumps(result, ensure_ascii=False))
async def main():
async with aiohttp.ClientSession() as session:
# 获取工具列表
print("获取工具列表...")
async with session.get(f"{base_url}/list") as response:
if response.status == 200:
tools = await response.json()
print("可用工具:")
for tool in tools:
print(json.dumps(tool, ensure_ascii=False))
else:
print(f"获取工具列表失败: {response.status}")
return
# await run1(session)
await run2(session)
# await run2(session)
await run3(session)
if __name__ == "__main__":
asyncio.run(main())

6
test/test5.py Normal file
View file

@ -0,0 +1,6 @@
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
# r = enc.encode("我爱吃西瓜,你说啥")
r = enc.encode("hello world aaaaaaaaaaaa")
print(len(r))

13
test/test6.py Normal file
View file

@ -0,0 +1,13 @@
import tiktoken
def count_tokens(text: str) -> int:
"""计算给定文本在指定模型下的 token 数量"""
encoding = tiktoken.get_encoding("o200k_base")
tokens = encoding.encode(text)
return len(tokens)
# 示例使用
text = "你好世界Hello, world!"
token_count = count_tokens(text)
print(f"Token 数量: {token_count}")
print(len(text) / 4)