Skip to content

Commit f85cb62

Browse files
committed
feat(agent): persist routing state across HTTP turns for agent delegation
- Add current_active_agent and supervisor_agent to ABIAgentState for routing persistence - Hydrate routing state from checkpointed graph on new HTTP turn - Persist routing state into checkpointed graph state to survive agent tree rebuilds - Improve error handling for unknown tools to avoid failures - Update handoff tool to include current active agent in state updates This enables seamless agent delegation continuity across HTTP requests by storing routing state in the checkpointed graph.
1 parent b7c4187 commit f85cb62

1 file changed

Lines changed: 81 additions & 6 deletions

File tree

  • libs/naas-abi-core/naas_abi_core/services/agent

libs/naas-abi-core/naas_abi_core/services/agent/Agent.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,12 @@ def set_requesting_help(self, requesting_help: bool):
285285

286286
class ABIAgentState(MessagesState):
287287
system_prompt: str
288+
# Routing state persisted through the LangGraph checkpointer so that agent
289+
# delegation survives across separate HTTP turns (each turn rebuilds the
290+
# agent tree with a fresh AgentSharedState, so in-memory routing alone is
291+
# lost between requests).
292+
current_active_agent: Optional[str]
293+
supervisor_agent: Optional[str]
288294

289295

290296
@dataclass
@@ -788,6 +794,19 @@ def current_active_agent(self, state: ABIAgentState) -> Command:
788794
Returns:
789795
Command: Command to goto the current active agent
790796
"""
797+
# Restore routing from the checkpointed graph state. Within a single
798+
# long-lived instance ``self._state`` is already authoritative, so we
799+
# only hydrate when it is empty — which is exactly the case at the start
800+
# of a new HTTP turn, where the agent tree was freshly duplicated with a
801+
# blank AgentSharedState. This is what makes a prior handoff survive
802+
# across requests instead of falling back to the supervisor every turn.
803+
persisted_active = state.get("current_active_agent")
804+
if persisted_active is not None and self._state.current_active_agent is None:
805+
self._state.set_current_active_agent(persisted_active)
806+
persisted_supervisor = state.get("supervisor_agent")
807+
if persisted_supervisor is not None and self._state.supervisor_agent is None:
808+
self._state.set_supervisor_agent(persisted_supervisor)
809+
791810
# Log the current active agent
792811
logger.debug(f"😏 Supervisor agent: '{self._state.supervisor_agent}'")
793812
logger.debug(f"🟢 Active agent: '{self._state.current_active_agent}'")
@@ -824,7 +843,10 @@ def current_active_agent(self, state: ABIAgentState) -> Command:
824843
self._notify_agent_routing(agent_name)
825844
return Command(
826845
goto=agent_name,
827-
update={"messages": state["messages"]},
846+
update={
847+
"messages": state["messages"],
848+
"current_active_agent": agent_name,
849+
},
828850
)
829851
else:
830852
logger.debug(f"❌ Agent '{at_mention}' not found")
@@ -1034,6 +1056,16 @@ def call_model(
10341056
logger.debug(f"🧠 Calling model for agent '{self._name}'")
10351057
self._notify_call_model(self._name)
10361058

1059+
# Persist routing into the checkpointed graph state so that the next
1060+
# HTTP turn (which rebuilds the agent tree from scratch) restores who is
1061+
# currently handling the conversation instead of resetting to the
1062+
# supervisor. A handoff downstream (see make_handoff_tool) overrides
1063+
# this value within the same step.
1064+
routing_update: dict[str, Any] = {
1065+
"current_active_agent": self._state.current_active_agent,
1066+
"supervisor_agent": self._state.supervisor_agent,
1067+
}
1068+
10371069
# Inserting system prompt before messages.
10381070
messages = state["messages"]
10391071
if (
@@ -1056,11 +1088,12 @@ def call_model(
10561088
return Command(
10571089
goto="__end__",
10581090
update={
1091+
**routing_update,
10591092
"messages": [
10601093
AIMessage(
10611094
content=f"I'm sorry, I encountered an error while processing your request:\n\n{e}"
10621095
)
1063-
]
1096+
],
10641097
},
10651098
)
10661099
logger.debug(f"Model response: {response}")
@@ -1079,12 +1112,18 @@ def call_model(
10791112
#### -----> A solution would be to rebuild the state to make sure that the following message of a tool call it the response of that call. If we do that we should theroetically be able to call multiple tools at once, which would be more effective.
10801113
# response.tool_calls = [response.tool_calls[0]]
10811114

1082-
return Command(goto="call_tools", update={"messages": [response]})
1115+
return Command(
1116+
goto="call_tools",
1117+
update={**routing_update, "messages": [response]},
1118+
)
10831119

10841120
if self._markdown_pretty_display:
10851121
logger.debug("Applying Markdown pretty display to response")
10861122
response = self._pretty_display_markdown(response)
1087-
return Command(goto="__end__", update={"messages": [response]})
1123+
return Command(
1124+
goto="__end__",
1125+
update={**routing_update, "messages": [response]},
1126+
)
10881127

10891128
def call_tools(self, state: ABIAgentState) -> list[Command]:
10901129
# Check if messages are present in the state.
@@ -1121,7 +1160,37 @@ def call_tools(self, state: ABIAgentState) -> list[Command]:
11211160
for tool_call in tool_calls:
11221161
tool_name: str = tool_call["name"]
11231162
logger.debug(f"🛠️ Calling tool: {tool_name}")
1124-
tool_: BaseTool = self._tools_by_name[tool_name]
1163+
1164+
# Guard against hallucinated / out-of-scope tool names. A raw dict
1165+
# lookup here would raise KeyError and kill the whole invoke thread
1166+
# (the user sees a generic crash). Instead, surface a ToolMessage the
1167+
# model can read so it can self-correct on the next loop.
1168+
tool_ = self._tools_by_name.get(tool_name)
1169+
if tool_ is None:
1170+
available = sorted(self._tools_by_name.keys())
1171+
logger.error(
1172+
f"🚨 Agent '{self._name}' tried to call unknown tool "
1173+
f"'{tool_name}'. Available tools: {available}"
1174+
)
1175+
had_tool_error = True
1176+
results.append(
1177+
Command(
1178+
update={
1179+
"messages": [
1180+
ToolMessage(
1181+
content=(
1182+
f"Tool '{tool_name}' is not available to "
1183+
f"agent '{self._name}'. Available tools: "
1184+
f"{available}"
1185+
),
1186+
name=tool_name,
1187+
tool_call_id=tool_call["id"],
1188+
)
1189+
]
1190+
},
1191+
)
1192+
)
1193+
continue
11251194

11261195
tool_input_fields = tool_.get_input_schema().model_json_schema()[
11271196
"properties"
@@ -1881,7 +1950,13 @@ def handoff_to_agent(
18811950
# This is the state update that the agent `agent_name` will see when it is invoked.
18821951
# We're passing agent's FULL internal message history AND adding a tool message to make sure
18831952
# the resulting chat history is valid. See the paragraph above for more information.
1884-
update={"messages": state["messages"] + [tool_message]},
1953+
# ``current_active_agent`` is persisted through the checkpointer so the
1954+
# delegation is restored on the next turn instead of routing back to
1955+
# the supervisor.
1956+
update={
1957+
"messages": state["messages"] + [tool_message],
1958+
"current_active_agent": agent_name,
1959+
},
18851960
)
18861961

18871962
assert isinstance(handoff_to_agent, BaseTool)

0 commit comments

Comments
 (0)