Skip to content
Open
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
79 changes: 73 additions & 6 deletions reme/components/agent_wrapper/as_agent_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
ToolResultStartEvent,
ToolResultTextDeltaEvent,
)
from agentscope.message import TextBlock, ToolResultState, UserMsg
from agentscope.message import AssistantMsg, TextBlock, ToolResultState, UserMsg
from agentscope.permission import PermissionBehavior, PermissionContext, PermissionDecision, PermissionMode
from agentscope.state import AgentState
from agentscope.tool import (
Expand Down Expand Up @@ -307,21 +307,88 @@ async def _build_agent(self, inputs: Any, **kwargs) -> tuple[Agent, Any]:

return agent, inputs

async def reply(self, inputs: Any, **kwargs) -> dict:
async def reply(self, inputs: Any, answer_reach_limit: bool = False, **kwargs) -> dict:
kwargs = self._merged_kwargs(kwargs)
agent, inputs = await self._build_agent(inputs, **kwargs)

await agent.observe(inputs)
await agent.reply()
final_msg = await agent.reply()

result_text = final_msg.get_text_content()

# When the agent hits max_iters, AgentScope returns a placeholder
# message instead of a real answer. If answer_reach_limit is True,
# make a follow-up LLM call to force a final answer from the existing
# context — preserving system prompt, summary, and tools for KV cache.
forced_answer = False
if answer_reach_limit and agent.state.cur_iter >= agent.react_config.max_iters:
try:
# Get the same input as normal reasoning (system + summary + context + tools)
model_input = await agent._prepare_model_input() # pylint: disable=protected-access
messages = model_input["messages"]
tools = model_input["tools"]

# Append force-answer prompt (only append, no modification)
messages.append(
UserMsg(
name="user",
content="You have reached the maximum number of reasoning steps and tool calls. "
"Based on the conversation history above, provide your final "
"answer directly.",
),
)

# Call model via agent to reuse retry logic and middleware
response = await agent._call_model( # pylint: disable=protected-access
messages=messages,
tools=tools,
tool_choice=ToolChoice(mode="none"),
)
# _call_model may return an async generator if the model is
# configured for streaming; collect the final chunk.
if hasattr(response, "__aiter__"):
last_chunk = None
async for chunk in response:
last_chunk = chunk
response = last_chunk

if response is not None:
forced_text = "\n".join(b.text for b in response.content if getattr(b, "type", None) == "text")
if forced_text:
result_text = forced_text
forced_answer = True
# Persist the forced answer into context so it is
# saved to state and reused by the output_schema path.
agent.state.context.append(
AssistantMsg(
name=agent.name,
content=[TextBlock(type="text", text=forced_text)],
),
)
except Exception:
self.logger.warning(
"Forced answer call failed, using original result",
exc_info=True,
)

# Persist state AFTER the forced-answer logic so the forced answer
# (appended to context above) is included in the saved session.
await self._dump_state(agent.state)
last_msg = agent.state.context[-1]

result = {
"session_id": agent.state.session_id,
"last_message": last_msg.model_dump(),
"result": last_msg.get_text_content(),
"last_message": final_msg.model_dump(),
"result": result_text,
}

# If forced answer was used, update last_message to reflect the actual answer
if forced_answer:
last_msg_dict = final_msg.model_dump()
last_msg_dict["content"] = [
TextBlock(type="text", text=result_text).model_dump(),
]
result["last_message"] = last_msg_dict

output_schema: dict | None = kwargs.get("output_schema")
if output_schema is not None:
assert self.as_llm is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
Expand Down
12 changes: 10 additions & 2 deletions reme/components/agent_wrapper/base_agent_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,16 @@ def _chunk(chunk_type: ChunkEnum = ChunkEnum.CONTENT, **kwargs: Any) -> StreamCh
return StreamChunk(chunk_type=chunk_type, **kwargs)

@abstractmethod
async def reply(self, inputs: Any, **kwargs) -> dict:
"""Send inputs to the agent and return a dict with session_id and last_message."""
async def reply(self, inputs: Any, answer_reach_limit: bool = False, **kwargs) -> dict:
"""Send inputs to the agent and return a dict with session_id and last_message.

Args:
inputs: The user input / prompt to send to the agent.
answer_reach_limit: When True, if the agent hits its reasoning/turn
limit, force it to produce a final answer from the existing
context instead of returning an empty or error result.
**kwargs: Additional backend-specific options.
"""

async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream agent events as unified StreamChunk objects."""
126 changes: 120 additions & 6 deletions reme/components/agent_wrapper/cc_agent_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,30 +482,144 @@ def _result_message_is_error(msg: Any) -> bool:

return isinstance(subtype, str) and subtype.lower() in {"error", "failed", "failure"}

@staticmethod
def _is_max_turns_result(msg: Any) -> bool:
"""Return whether an SDK ResultMessage represents a max-turns limit."""
subtype = getattr(msg, "subtype", None)
return isinstance(subtype, str) and subtype == "error_max_turns"

@staticmethod
def _is_trailing_success_error(exc: Exception) -> bool:
"""Return whether an SDK iterator error is the known success-exit artifact."""
return "Claude Code returned an error result: success" in str(exc)

# ----- reply / reply_stream --------------------------------------------

async def reply(self, inputs: Any, **kwargs) -> dict:
@staticmethod
async def _deny_all_tools(_input: Any, _tool_use_id: str | None, _context: Any) -> dict[str, Any]:
"""PreToolUse hook that hard-denies every tool call.

Used during the forced-answer step: even if the model ignores the
instruction and tries to call a tool, the CLI denies it at PreToolUse,
so the model cannot execute or keep calling tools and must produce a
direct text answer. This is a runtime permission decision — it does not
alter the tool schema, so the resumed prompt prefix (and its KV cache)
stays intact. Mirrors the AgentScope backend's ``tool_choice="none"``
hard guarantee.
"""
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"The reasoning/tool-call limit has been reached. Answer "
"directly from the existing context without calling tools."
),
},
}

async def _force_answer(self, session_id: str, **kwargs) -> str | None:
"""Force a final text answer after Claude Code hit its turn limit.

Resumes the existing session so the model keeps the full conversation
history *and the identical tool schema* (preserving the KV cache),
then hard-blocks tool execution via a PreToolUse deny hook so the
model is forced to answer directly.

Two layers work together:

1. An appended instruction asks the model to answer directly without
calling tools or reasoning further.
2. :meth:`_deny_all_tools` denies every tool at PreToolUse, so if the
model ignores (1) and still tries a tool, it is forcibly stopped
from executing / continuing tool calls. Because the hook is a
runtime decision (not a schema change), the resumed prompt prefix
stays byte-identical and the KV cache is reused.
"""
from claude_agent_sdk import query, ResultMessage, HookMatcher

prompt = (
"You have reached the maximum number of reasoning steps and tool calls. "
"Do NOT call any tools and do NOT perform any further reasoning. "
"Based on the conversation history above, provide your final answer directly."
)

# Drop output_schema so the forced call returns plain text.
forced_kwargs = {k: v for k, v in kwargs.items() if k != "output_schema"}
opts = self._build_options(prompt, stream=False, **forced_kwargs)

# Resume the just-finished session. Tools / mcp_servers / system prompt
# are left untouched so the prompt prefix is identical and the KV cache
# is reused.
opts.resume = session_id
opts.session_id = None
opts.continue_conversation = False

# Hard stop: deny every tool at PreToolUse. The matcher is left as the
# default (matches all tools) so both builtin and job/MCP tools are
# blocked. Hooks keep the control channel (stdin) open, so this works
# with the plain string prompt above.
opts.hooks = {"PreToolUse": [HookMatcher(hooks=[self._deny_all_tools])]}

last_msg = None
try:
async for msg in query(prompt=prompt, options=opts):
if isinstance(msg, ResultMessage):
last_msg = msg
except Exception as exc:
if last_msg is None:
raise
self.logger.debug(f"Ignoring Claude Code trailing error in forced answer: {exc}")

return last_msg.result if last_msg is not None else None

async def reply(self, inputs: Any, answer_reach_limit: bool = False, **kwargs) -> dict:
from claude_agent_sdk import query, ResultMessage

opts = self._build_options(inputs, stream=False, **kwargs)

last_msg = None
async for msg in query(prompt=inputs, options=opts):
if isinstance(msg, ResultMessage):
last_msg = msg
try:
async for msg in query(prompt=inputs, options=opts):
if isinstance(msg, ResultMessage):
last_msg = msg
except Exception as exc:
# On max_turns the CLI emits the ResultMessage and then exits
# non-zero, raising a trailing error. Only swallow it when we can
# act on the captured result (answer_reach_limit); otherwise keep
# the original raising behavior for backward compatibility.
if not (answer_reach_limit and last_msg is not None):
raise
self.logger.debug(f"Ignoring Claude Code trailing error after final result: {exc}")

if last_msg is None:
raise ValueError("No message received from Claude Code.")

result_text = last_msg.result

# When the agent hits max_turns, Claude Code returns an error result
# (subtype "error_max_turns") instead of a real answer. If
# answer_reach_limit is True, resume the session and force a final
# answer with tools disabled.
forced_answer = False
if answer_reach_limit and self._is_max_turns_result(last_msg) and last_msg.session_id:
try:
forced_text = await self._force_answer(last_msg.session_id, **kwargs)
if forced_text:
result_text = forced_text
forced_answer = True
except Exception:
self.logger.warning("Forced answer call failed, using original result", exc_info=True)

last_message = asdict(last_msg)
if forced_answer:
# Reflect the forced answer so result and last_message stay consistent.
last_message["result"] = result_text

result = {
"session_id": last_msg.session_id or "",
"last_message": asdict(last_msg),
"result": last_msg.result,
"last_message": last_message,
"result": result_text,
}
output_schema = kwargs.get("output_schema") or self.kwargs.get("output_schema")
if output_schema and last_msg.structured_output:
Expand Down
2 changes: 1 addition & 1 deletion reme/config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ components:
default:
backend: local
store_name: local
# embedding_store: default
# embedding_store: default
embedding_store: ""
keyword_index: default
file_graph: default
Loading
Loading