chore: 添加虚拟环境到仓库

- 添加 backend_service/venv 虚拟环境
- 包含所有Python依赖包
- 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
2025-12-03 10:19:25 +08:00
parent a6c2027caa
commit c4f851d387
12655 changed files with 3009376 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
"""The plan module in AgentScope."""
from ._plan_model import (
SubTask,
Plan,
)
from ._plan_notebook import (
DefaultPlanToHint,
PlanNotebook,
)
from ._storage_base import PlanStorageBase
from ._in_memory_storage import InMemoryPlanStorage
__all__ = [
"SubTask",
"Plan",
"DefaultPlanToHint",
"PlanNotebook",
"PlanStorageBase",
"InMemoryPlanStorage",
]

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""The in-memory plan storage class."""
from collections import OrderedDict
from ._plan_model import Plan
from ._storage_base import PlanStorageBase
class InMemoryPlanStorage(PlanStorageBase):
"""In-memory plan storage."""
def __init__(self) -> None:
"""Initialize the in-memory plan storage."""
super().__init__()
self.plans = OrderedDict()
# Support historical plan serialization and deserialization
self.register_state(
"plans",
lambda plans: {k: v.model_dump() for k, v in plans.items()},
lambda json_data: OrderedDict(
(k, Plan.model_validate(v)) for k, v in json_data.items()
),
)
async def add_plan(self, plan: Plan, override: bool = True) -> None:
"""Add a plan to the storage.
Args:
plan (`Plan`):
The plan to be added.
override (`bool`, defaults to `True`):
Whether to override the existing plan with the same ID.
"""
if plan.id in self.plans and not override:
raise ValueError(
f"Plan with id {plan.id} already exists.",
)
self.plans[plan.id] = plan
async def delete_plan(self, plan_id: str) -> None:
"""Delete a plan from the storage.
Args:
plan_id (`str`):
The ID of the plan to be deleted.
"""
self.plans.pop(plan_id, None)
async def get_plans(self) -> list[Plan]:
"""Get all plans from the storage.
Returns:
`list[Plan]`:
A list of all plans in the storage.
"""
return list(self.plans.values())
async def get_plan(self, plan_id: str) -> Plan | None:
"""Get a plan by its ID.
Args:
plan_id (`str`):
The ID of the plan to be retrieved.
Returns:
`Plan | None`:
The plan with the specified ID, or None if not found.
"""
return self.plans.get(plan_id, None)

View File

@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
"""The models used in the plan module."""
from typing import Literal
import shortuuid
from pydantic import BaseModel, Field
from .._utils._common import _get_timestamp
class SubTask(BaseModel):
"""The subtask model used in the plan module."""
name: str = Field(
description=(
"The subtask name, should be concise, descriptive and not"
"exceed 10 words."
),
)
description: str = Field(
description=(
"The subtask description, including the constraints, target and "
"outcome to be achieved. The description should be clear, "
"specific and concise, and all the constraints, target and "
"outcome should be specific and measurable."
),
)
expected_outcome: str = Field(
description=(
"The expected outcome of the subtask, which should be specific, "
"concrete and measurable."
),
)
outcome: str | None = Field(
description="The actual outcome of the subtask.",
default=None,
)
state: Literal["todo", "in_progress", "done", "abandoned"] = Field(
description="The state of the subtask.",
default="todo",
)
created_at: str = Field(
description="The time the subtask was created.",
default_factory=_get_timestamp,
)
# Result related fields
finished_at: str | None = Field(
description="The time the subtask was finished.",
default=None,
)
def finish(self, outcome: str) -> None:
"""Finish the subtask with the actual outcome."""
self.state = "done"
self.outcome = outcome
self.finished_at = _get_timestamp()
def to_oneline_markdown(self) -> str:
"""Convert the subtask to MarkDown format."""
status_map = {
"todo": "- []",
"in_progress": "- [][WIP]",
"done": "- [x]",
"abandoned": "- [][Abandoned]",
}
return f"{status_map[self.state]} {self.name}"
def to_markdown(self, detailed: bool = False) -> str:
"""Convert the subtask to MarkDown format.
Args:
detailed (`bool`, defaults to `False`):
Whether to include detailed information about the subtask.
"""
status_map = {
"todo": "- [ ] ",
"in_progress": "- [ ] [WIP]",
"done": "- [x] ",
"abandoned": "- [ ] [Abandoned]",
}
if detailed:
markdown_strs = [
f"{status_map[self.state]}{self.name}",
f"\t- Created At: {self.created_at}",
f"\t- Description: {self.description}",
f"\t- Expected Outcome: {self.expected_outcome}",
f"\t- State: {self.state}",
]
if self.state == "done":
markdown_strs.extend(
[
f"\t- Finished At: {self.finished_at}",
f"\t- Actual Outcome: {self.outcome}",
],
)
return "\n".join(markdown_strs)
return f"{status_map[self.state]}{self.name}"
class Plan(BaseModel):
"""The plan model used in the plan module, contains a list of subtasks."""
id: str = Field(default_factory=shortuuid.uuid)
name: str = Field(
description=(
"The plan name, should be concise, descriptive and not exceed 10 "
"words."
),
)
description: str = Field(
description=(
"The plan description, including the constraints, target and "
"outcome to be achieved. The description should be clear, "
"specific and concise, and all the constraints, target and "
"outcome should be specific and measurable."
),
)
expected_outcome: str = Field(
description=(
"The expected outcome of the plan, which should be specific, "
"concrete and measurable."
),
)
subtasks: list[SubTask] = Field(
description=("A list of subtasks that make up the plan."),
)
created_at: str = Field(
description="The time the plan was created.",
default_factory=_get_timestamp,
)
state: Literal["todo", "in_progress", "done", "abandoned"] = Field(
description="The state of the plan.",
default="todo",
)
finished_at: str | None = Field(
description="The time the plan was finished.",
default=None,
)
outcome: str | None = Field(
description="The actual outcome of the plan.",
default=None,
)
def refresh_plan_state(self) -> str:
"""Refresh the plan state based on the states of its subtasks. This
function only switches the plan state between "todo" and "in_progress".
# TODO: Handle the plan state much more formally.
"""
if self.state in ["done", "abandoned"]:
return ""
any_in_progress = any(_.state == "in_progress" for _ in self.subtasks)
if any_in_progress and self.state == "todo":
self.state = "in_progress"
return "The plan state has been updated to 'in_progress'."
elif not any_in_progress and self.state == "in_progress":
self.state = "todo"
return "The plan state has been updated to 'todo'."
return ""
def finish(
self,
state: Literal["done", "abandoned"],
outcome: str,
) -> None:
"""Finish the plan."""
self.state = state
self.outcome = outcome
self.finished_at = _get_timestamp()
def to_markdown(self, detailed: bool = False) -> str:
"""Convert the plan to MarkDown format."""
subtasks_markdown = "\n".join(
[
subtask.to_markdown(
detailed=detailed,
)
for subtask in self.subtasks
],
)
return "\n".join(
[
f"# {self.name}",
f"**Description**: {self.description}",
f"**Expected Outcome**: {self.expected_outcome}",
f"**State**: {self.state}",
f"**Created At**: {self.created_at}",
"## Subtasks",
subtasks_markdown,
],
)

View File

@@ -0,0 +1,892 @@
# -*- coding: utf-8 -*-
"""The plan notebook class, used to manage the plan, providing hints and
tool functions to the agent."""
from collections import OrderedDict
from typing import Callable, Literal, Coroutine, Any
from ._in_memory_storage import InMemoryPlanStorage
from ._plan_model import SubTask, Plan
from ._storage_base import PlanStorageBase
from .._utils._common import _execute_async_or_sync_func
from ..message import TextBlock, Msg
from ..module import StateModule
from ..tool import ToolResponse
class DefaultPlanToHint:
"""The default function to generate the hint message based on the current
plan to guide the agent on next steps."""
hint_prefix: str = "<system-hint>"
hint_suffix: str = "</system-hint>"
no_plan: str = (
"If the user's query is complex (e.g. programming a website, game or "
"app), or requires a long chain of steps to complete (e.g. conduct "
"research on a certain topic from different sources), you NEED to "
"create a plan first by calling 'create_plan'. Otherwise, you can "
"directly execute the user's query without planning."
)
at_the_beginning: str = (
"The current plan:\n"
"```\n"
"{plan}\n"
"```\n"
"Your options include:\n"
"- Mark the first subtask as 'in_progress' by calling "
"'update_subtask_state' with subtask_idx=0 and state='in_progress', "
"and start executing it.\n"
"- If the first subtask is not executable, analyze why and what you "
"can do to advance the plan, e.g. ask user for more information, "
"revise the plan by calling 'revise_current_plan'.\n"
"- If the user asks you to do something unrelated to the plan, "
"prioritize the completion of user's query first, and then return "
"to the plan afterward.\n"
"- If the user no longer wants to perform the current plan, confirm "
"with the user and call the 'finish_plan' function.\n"
)
when_a_subtask_in_progress: str = (
"The current plan:\n"
"```\n"
"{plan}\n"
"```\n"
"Now the subtask at index {subtask_idx}, named '{subtask_name}', is "
"'in_progress'. Its details are as follows:\n"
"```\n"
"{subtask}\n"
"```\n"
"Your options include:\n"
"- Go on execute the subtask and get the outcome.\n"
"- Call 'finish_subtask' with the specific outcome if the subtask is "
"finished.\n"
"- Ask the user for more information if you need.\n"
"- Revise the plan by calling 'revise_current_plan' if necessary.\n"
"- If the user asks you to do something unrelated to the plan, "
"prioritize the completion of user's query first, and then return to "
"the plan afterward."
)
when_no_subtask_in_progress: str = (
"The current plan:\n"
"```\n"
"{plan}\n"
"```\n"
"The first {index} subtasks are done, and there is no subtask "
"'in_progress'. Now Your options include:\n"
"- Mark the next subtask as 'in_progress' by calling "
"'update_subtask_state', and start executing it.\n"
"- Ask the user for more information if you need.\n"
"- Revise the plan by calling 'revise_current_plan' if necessary.\n"
"- If the user asks you to do something unrelated to the plan, "
"prioritize the completion of user's query first, and then return to "
"the plan afterward."
)
at_the_end: str = (
"The current plan:\n"
"```\n"
"{plan}\n"
"```\n"
"All the subtasks are done. Now your options are:\n"
"- Finish the plan by calling 'finish_plan' with the specific "
"outcome, and summarize the whole process and outcome to the user.\n"
"- Revise the plan by calling 'revise_current_plan' if necessary.\n"
"- If the user asks you to do something unrelated to the plan, "
"prioritize the completion of user's query first, and then return to "
"the plan afterward."
)
def __call__(self, plan: Plan | None) -> str | None:
"""Generate the hint message based on the input plan to guide the
agent on next steps.
Args:
plan (`Plan | None`):
The current plan, used to generate the hint message.
Returns:
`str | None`:
The generated hint message, or None if the plan is None or
there is no relevant hint.
"""
if plan is None:
hint = self.no_plan
else:
# Count the number of subtasks in each state
n_todo, n_in_progress, n_done, n_abandoned = 0, 0, 0, 0
in_progress_subtask_idx = None
for idx, subtask in enumerate(plan.subtasks):
if subtask.state == "todo":
n_todo += 1
elif subtask.state == "in_progress":
n_in_progress += 1
in_progress_subtask_idx = idx
elif subtask.state == "done":
n_done += 1
elif subtask.state == "abandoned":
n_abandoned += 1
hint = None
if n_in_progress == 0 and n_done == 0:
# All subtasks are todo
hint = self.at_the_beginning.format(
plan=plan.to_markdown(),
)
elif n_in_progress > 0 and in_progress_subtask_idx is not None:
# One subtask is in_progress
hint = self.when_a_subtask_in_progress.format(
plan=plan.to_markdown(),
subtask_idx=in_progress_subtask_idx,
subtask_name=plan.subtasks[in_progress_subtask_idx].name,
subtask=plan.subtasks[in_progress_subtask_idx].to_markdown(
detailed=True,
),
)
elif n_in_progress == 0 and n_done > 0:
# No subtask is in_progress, and some subtasks are done
hint = self.when_no_subtask_in_progress.format(
plan=plan.to_markdown(),
index=n_done,
)
elif n_done + n_abandoned == len(plan.subtasks):
# All subtasks are done or abandoned
hint = self.at_the_end.format(
plan=plan.to_markdown(),
)
if hint:
return f"{self.hint_prefix}{hint}{self.hint_suffix}"
return hint
class PlanNotebook(StateModule):
"""The plan notebook to manage the plan, providing hints and plan related
tool functions to the agent."""
_plan_change_hooks: dict[str, Callable[["PlanNotebook", Plan], None]]
"""The hooks that will be triggered when the plan is changed. For example,
used to display the plan on the frontend."""
description: str = (
"The plan-related tools. Activate this tool when you need to execute "
"complex task, e.g. building a website or a game. Once activated, "
"you'll enter the plan mode, where you will be guided to complete "
"the given query by creating and following a plan, and hint message "
"wrapped by <system-hint></system-hint> will guide you to complete "
"the task. If you think the user no longer wants to perform the "
"current task, you need to confirm with the user and call the "
"'finish_plan' function."
)
def __init__(
self,
max_subtasks: int | None = None,
plan_to_hint: Callable[[Plan | None], str | None] | None = None,
storage: PlanStorageBase | None = None,
) -> None:
"""Initialize the plan notebook.
Args:
max_subtasks (`int | None`, optional):
The maximum number of subtasks in a plan.
plan_to_hint (`Callable[[Plan | None], str | None] | None`, \
optional):
The function to generate the hint message based on the
current plan. If not provided, a default `DefaultPlanToHint`
object will be used.
storage (`PlanStorageBase | None`, optional):
The plan storage. If not provided, an in-memory storage will
be used.
"""
super().__init__()
self.max_tasks = max_subtasks
self.plan_to_hint = plan_to_hint or DefaultPlanToHint()
self.storage = storage or InMemoryPlanStorage()
self.current_plan: Plan | None = None
self._plan_change_hooks = OrderedDict()
# Register the current_plan state for state management
self.register_state(
"current_plan",
custom_to_json=lambda _: _.model_dump() if _ else None,
custom_from_json=lambda _: Plan.model_validate(_) if _ else None,
)
async def create_plan(
self,
name: str,
description: str,
expected_outcome: str,
subtasks: list[SubTask],
) -> ToolResponse:
"""Create a plan by given name and sub-tasks.
Args:
name (`str`):
The plan name, should be concise, descriptive and not exceed
10 words.
description (`str`):
The plan description, including the constraints, target and
outcome to be achieved. The description should be clear,
specific and concise, and all the constraints, target and
outcome should be specific and measurable.
expected_outcome (`str`):
The expected outcome of the plan, which should be specific,
concrete and measurable.
subtasks (`list[SubTask]`):
A list of sequential sub-tasks that make up the plan.
Returns:
`ToolResponse`:
The response of the tool call.
"""
plan = Plan(
name=name,
description=description,
expected_outcome=expected_outcome,
subtasks=subtasks,
)
if self.current_plan is None:
res = ToolResponse(
content=[
TextBlock(
type="text",
text=f"Plan '{name}' created successfully.",
),
],
)
else:
res = ToolResponse(
content=[
TextBlock(
type="text",
text=(
"The current plan named "
f"'{self.current_plan.name}' is replaced by the "
f"newly created plan named '{name}'."
),
),
],
)
self.current_plan = plan
await self._trigger_plan_change_hooks()
return res
def _validate_current_plan(self) -> None:
"""Validate the current plan."""
if self.current_plan is None:
raise ValueError(
"The current plan is None, you need to create a plan by "
"calling create_plan() first.",
)
async def revise_current_plan(
self,
subtask_idx: int,
action: Literal["add", "revise", "delete"],
subtask: SubTask | None = None,
) -> ToolResponse:
"""Revise the current plan by adding, revising or deleting a sub-task.
Args:
subtask_idx (`int`):
The index of the sub-task to be revised, starting from 0.
action (`Literal["add", "revise", "delete"]`):
The action to be performed on the sub-task. If "add", the
sub-task will be inserted before the given index. If "revise",
the sub-task at the given index will be revised. If "delete",
the sub-task at the given index will be deleted.
subtask (`SubTask | None`, optional):
The sub-task to be added or revised. Required if action is
"add" or "revise".
Raises:
`ValueError`:
If the current plan is `None`, `ValueError` will be raised.
Returns:
`ToolResponse`:
The response of the tool call.
"""
# Validate the arguments first
response: list[str] = []
if isinstance(subtask_idx, str):
try:
subtask_idx = int(subtask_idx)
except ValueError:
pass
if not isinstance(subtask_idx, int):
response.append(
f"Invalid type for argument 'subtask_idx'. "
f"Expected 'int', but got '{type(subtask_idx)}'.",
)
if action not in ["add", "revise", "delete"]:
response.append(
f"Invalid action '{action}'. Must be one of 'add', 'revise', "
f"'delete'.",
)
if action in ["add", "revise"] and subtask is None:
response.append(
f"The subtask must be provided when action is '{action}', "
"but got None.",
)
self._validate_current_plan()
# validate subtask_idx
if action != "add" and subtask_idx >= len(self.current_plan.subtasks):
response.append(
f"Invalid subtask_idx '{subtask_idx}' for action '{action}'. "
f"Must be between 0 "
f"and {len(self.current_plan.subtasks) - 1}.",
)
if action == "add" and not (
0 <= subtask_idx <= len(self.current_plan.subtasks)
):
response.append(
f"Invalid subtask_idx '{subtask_idx}' for action 'add'. "
f"Must be between 0 and {len(self.current_plan.subtasks)}.",
)
if response:
return ToolResponse(
content=[
TextBlock(
type="text",
text="Error: " + response[0],
),
],
)
if subtask is not None:
# Convert to SubTask model if it's a dict
subtask = SubTask.model_validate(subtask)
if action == "delete":
subtask = self.current_plan.subtasks.pop(subtask_idx)
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Subtask (named '{subtask.name}') at index "
f"{subtask_idx} is deleted successfully.",
),
],
)
if action == "add" and subtask:
self.current_plan.subtasks.insert(subtask_idx, subtask)
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"New subtask is added successfully at index "
f"{subtask_idx}.",
),
],
)
# revise
self.current_plan.subtasks[subtask_idx] = subtask
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Subtask at index {subtask_idx} is revised "
f"successfully.",
),
],
)
async def update_subtask_state(
self,
subtask_idx: int,
state: Literal["todo", "in_progress", "abandoned"],
) -> ToolResponse:
"""Update the state of a subtask by given index and state. Note if you
want to mark a subtask as done, you SHOULD call `finish_subtask`
instead with the specific outcome.
Args:
subtask_idx (`int`):
The index of the subtask to be updated, starting from 0.
state (`Literal["todo", "in_progress", "abandoned"]`):
The new state of the subtask. If you want to mark a subtask
as done, you SHOULD call `finish_subtask` instead with the
specific outcome.
"""
self._validate_current_plan()
if isinstance(subtask_idx, str):
try:
subtask_idx = int(subtask_idx)
except ValueError:
pass
if not isinstance(subtask_idx, int):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Invalid type for argument 'subtask_idx'. "
f"Expected 'int', but got '{type(subtask_idx)}'.",
),
],
)
if not 0 <= subtask_idx < len(self.current_plan.subtasks):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Invalid subtask_idx '{subtask_idx}'. Must "
f"be between 0 and "
f"{len(self.current_plan.subtasks) - 1}.",
),
],
)
if state not in ["todo", "in_progress", "abandoned"]:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Invalid state '{state}'. Must be one of "
"'todo', 'in_progress', 'abandoned'.",
),
],
)
# Only one subtask can be in_progress at a time
if state == "in_progress":
# Check only one subtask is in_progress
for idx, subtask in enumerate(self.current_plan.subtasks):
# Check all previous subtasks are done or deprecated
if idx < subtask_idx and subtask.state not in [
"done",
"abandoned",
]:
return ToolResponse(
content=[
TextBlock(
type="text",
text=(
f"Subtask (at index {idx}) named "
f"'{subtask.name}' is not done yet. You "
"should finish the previous subtasks "
"first."
),
),
],
)
# Check no other subtask is in_progress
if subtask.state == "in_progress":
return ToolResponse(
content=[
TextBlock(
type="text",
text=(
f"Subtask (at index {idx}) named "
f"'{subtask.name}' is already "
"'in_progress'. You should finish it "
"first before starting another subtask."
),
),
],
)
self.current_plan.subtasks[subtask_idx].state = state
# Update the plan state to in_progress if not already
suffix = self.current_plan.refresh_plan_state()
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Subtask at index {subtask_idx}, named "
f"'{self.current_plan.subtasks[subtask_idx].name}' "
f"is marked as '{state}' successfully. " + suffix,
),
],
)
async def finish_subtask(
self,
subtask_idx: int,
subtask_outcome: str,
) -> ToolResponse:
"""Label the subtask as done by given index and outcome.
Args:
subtask_idx (`int`):
The index of the sub-task to be marked as done, starting
from 0.
subtask_outcome (`str`):
The specific outcome of the sub-task, should exactly match the
expected outcome in the sub-task description. SHOULDN't be
what you did or general description, e.g. "I have searched
xxx", "I have written the code for xxx", etc. It SHOULD be
the specific data, information, or path to the file, e.g.
"There are 5 articles about xxx, they are\n- xxx\n- xxx\n..."
"""
self._validate_current_plan()
if isinstance(subtask_idx, str):
try:
subtask_idx = int(subtask_idx)
except ValueError:
pass
if not isinstance(subtask_idx, int):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Invalid type for argument 'subtask_idx'. "
f"Expected 'int', but got '{type(subtask_idx)}'.",
),
],
)
if not 0 <= subtask_idx < len(self.current_plan.subtasks):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Invalid subtask_idx '{subtask_idx}'. Must "
f"be between 0 and "
f"{len(self.current_plan.subtasks) - 1}.",
),
],
)
for idx, subtask in enumerate(
self.current_plan.subtasks[0:subtask_idx],
):
if subtask.state not in ["done", "abandoned"]:
return ToolResponse(
content=[
TextBlock(
type="text",
text=(
"Cannot finish subtask at index "
f"{subtask_idx} because the previous "
f"subtask (at index {idx}) named "
f"'{subtask.name}' is not done yet. You "
"should finish the previous subtasks first."
),
),
],
)
# Label the subtask as done
self.current_plan.subtasks[subtask_idx].finish(subtask_outcome)
# Auto activate the next subtask if exists
if subtask_idx + 1 < len(self.current_plan.subtasks):
self.current_plan.subtasks[subtask_idx + 1].state = "in_progress"
next_subtask = self.current_plan.subtasks[subtask_idx + 1]
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=(
f"Subtask (at index {subtask_idx}) named "
f"'{self.current_plan.subtasks[subtask_idx].name}'"
" is marked as done successfully. The next "
f"subtask named '{next_subtask.name}' is "
f"activated."
),
),
],
)
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=(
f"Subtask (at index {subtask_idx}) named "
f"'{self.current_plan.subtasks[subtask_idx].name}'"
" is marked as done successfully. "
),
),
],
)
async def view_subtasks(self, subtask_idx: list[int]) -> ToolResponse:
"""View the details of the sub-tasks by given indexes.
Args:
subtask_idx (`list[int]`):
The indexes of the sub-tasks to be viewed, starting from 0.
"""
self._validate_current_plan()
gathered_strs = []
invalid_subtask_idx = []
for idx in subtask_idx:
if not 0 <= idx < len(self.current_plan.subtasks):
invalid_subtask_idx.append(idx)
continue
subtask_markdown = self.current_plan.subtasks[idx].to_markdown(
detailed=True,
)
gathered_strs.append(
f"Subtask at index {idx}:\n"
"```\n"
f"{subtask_markdown}\n"
"```\n",
)
if invalid_subtask_idx:
gathered_strs.append(
f"Invalid subtask_idx '{invalid_subtask_idx}'. Must be "
f"between 0 and {len(self.current_plan.subtasks) - 1}.",
)
return ToolResponse(
content=[
TextBlock(
type="text",
text="\n".join(gathered_strs),
),
],
)
async def finish_plan(
self,
state: Literal["done", "abandoned"],
outcome: str,
) -> ToolResponse:
"""Finish the current plan by given outcome, or abandon it with the
given reason if the user no longer wants to perform it. Note that you
SHOULD confirm with the user before abandoning the plan.
Args:
state (`Literal["done", "abandoned"]`):
The state to finish the plan. If "done", the plan will be
marked as done with the given outcome. If "abandoned", the
plan will be abandoned with the given reason.
outcome (`str`):
The specific outcome of the plan if state is "done", or the
reason for abandoning the plan if state is "abandoned".
"""
if self.current_plan is None:
return ToolResponse(
content=[
TextBlock(
type="text",
text="There is no plan to finish.",
),
],
)
self.current_plan.finish(state, outcome)
# Store the finished plan into history
await self.storage.add_plan(self.current_plan)
self.current_plan = None
await self._trigger_plan_change_hooks()
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"The current plan is finished successfully as "
f"'{state}'.",
),
],
)
async def view_historical_plans(self) -> ToolResponse:
"""View the historical plans."""
historical_plans = await self.storage.get_plans()
plans_str = [
f"""Plan named '{_.name}':
- ID: {_.id}
- Created at: {_.created_at}
- Description: {_.description}
- State: {_.state}
"""
for _ in historical_plans
]
return ToolResponse(
content=[
TextBlock(
type="text",
text="\n".join(plans_str),
),
],
)
async def recover_historical_plan(self, plan_id: str) -> ToolResponse:
"""Recover a historical plan by given plan ID, the plan ID can be
obtained by calling `view_historical_plans`. Note the recover
operation will override the current plan if exists.
Args:
plan_id (`str`):
The ID of the historical plan to be recovered.
"""
historical_plan = await self.storage.get_plan(plan_id)
if historical_plan is None:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Cannot find the plan with ID '{plan_id}'.",
),
],
)
# Store the current plan into history if exists
if self.current_plan:
if self.current_plan.state != "done":
self.current_plan.finish(
"abandoned",
f"The plan execution is interrupted by a new plan "
f"with ID '{historical_plan.id}'.",
)
await self.storage.add_plan(self.current_plan)
res = ToolResponse(
content=[
TextBlock(
type="text",
text=(
"The current plan named "
f"'{self.current_plan.name}' is replaced by the "
f"historical plan named '{historical_plan.name}' "
f"with ID '{historical_plan.id}'."
),
),
],
)
else:
res = ToolResponse(
content=[
TextBlock(
type="text",
text=(
f"Historical plan named '{historical_plan.name}' "
f"with ID '{historical_plan.id}' is recovered "
"successfully."
),
),
],
)
self.current_plan = historical_plan
return res
def list_tools(
self,
) -> list[Callable[..., Coroutine[Any, Any, ToolResponse]]]:
"""List all tool functions provided to agent
Returns:
`list[Callable[..., ToolResponse]]`:
A list of all tool functions provided by the plan notebook to
the agent.
"""
return [
# subtask related tools
self.view_subtasks,
self.update_subtask_state,
self.finish_subtask,
# plan related tools
self.create_plan,
self.revise_current_plan,
self.finish_plan,
# historical plan related tools
self.view_historical_plans,
self.recover_historical_plan,
]
async def get_current_hint(self) -> Msg | None:
"""Get the hint message based on the current plan and subtasks states.
This function will call the `plan_to_hint` function to generate the
hint message.
Returns:
`Msg | None`:
The hint message wrapped by <system-hint></system-hint>, or
None if there is no relevant hint.
"""
hint_content = self.plan_to_hint(self.current_plan)
if hint_content:
msg = Msg(
"user",
hint_content,
"user",
)
return msg
return None
def register_plan_change_hook(
self,
hook_name: str,
hook: Callable[["PlanNotebook", Plan], None],
) -> None:
"""Register a plan hook that will be triggered when the plan is
changed.
Args:
hook_name (`str`):
The name of the hook, should be unique.
hook (`Callable[[Plan], None]`):
The hook function, which takes the current plan as input and
returns nothing.
"""
self._plan_change_hooks[hook_name] = hook
def remove_plan_change_hook(self, hook_name: str) -> None:
"""Remove a plan change hook by given name.
Args:
hook_name (`str`):
The name of the hook to be removed.
"""
if hook_name in self._plan_change_hooks:
self._plan_change_hooks.pop(hook_name)
else:
raise ValueError(f"Hook '{hook_name}' not found.")
async def _trigger_plan_change_hooks(self) -> None:
"""Trigger all the plan change hooks."""
for hook in self._plan_change_hooks.values():
await _execute_async_or_sync_func(
hook,
self,
self.current_plan,
)

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""The base class for plan storage."""
from abc import abstractmethod
from agentscope.module import StateModule
from agentscope.plan._plan_model import Plan
class PlanStorageBase(StateModule):
"""The base class for plan storage."""
@abstractmethod
async def add_plan(self, plan: Plan) -> None:
"""Add a plan to the storage."""
@abstractmethod
async def delete_plan(self, plan_id: str) -> None:
"""Delete a plan from the storage."""
@abstractmethod
async def get_plans(self) -> list[Plan]:
"""Get all plans from the storage."""
@abstractmethod
async def get_plan(self, plan_id: str) -> Plan | None:
"""Get a plan by its ID."""