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
7 changes: 2 additions & 5 deletions backend/app/api/chat_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
from app.services.agent_runtime.tool_execution import (
ToolExecutionError,
is_user_reconcilable_unknown_execution,
reconcile_unknown_tool_execution,
)
from app.services.participant_identity import get_or_create_user_participant
Expand Down Expand Up @@ -547,11 +548,7 @@ async def get_session_runtime_state(
and isinstance(execution.result_metadata.get("error_code"), str)
else None
),
can_reconcile=(
execution.tool_name == "write_file"
and execution.effect == "write"
and execution.retry_policy == "conditional"
),
can_reconcile=is_user_reconcilable_unknown_execution(execution),
)
for execution in pending_reconciliations
],
Expand Down
40 changes: 33 additions & 7 deletions backend/app/services/agent_runtime/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@
RetryPolicy = Literal["safe", "conditional", "never"]
SAFE_READ_MAX_ATTEMPTS = 3

# These tools dispatch an external image-generation request and can therefore
# leave the provider outcome uncertain after a response timeout. Direct Chat
# offers an explicit human confirmation before allowing the Run to continue.
_IMAGE_GENERATION_TOOL_NAMES = frozenset(
{
"generate_image_siliconflow",
"generate_image_openai",
"generate_image_google",
"generate_image_custom",
}
)

_PERSISTED_STATUSES = frozenset({"started", "succeeded", "failed", "unknown"})
_SIDE_EFFECT_CLASSIFICATIONS = frozenset({"read", "write", "external_write"})
_RETRY_POLICIES = frozenset({"safe", "conditional", "never"})
Expand Down Expand Up @@ -1856,15 +1868,10 @@ async def reconcile_unknown_tool_execution(
"tool_execution_scope_mismatch",
"tool execution does not belong to the requested run",
)
effect, retry_policy = _execution_metadata(execution)
if (
execution.tool_name != "write_file"
or effect != "write"
or retry_policy != "conditional"
):
if not is_user_reconcilable_unknown_execution(execution):
raise ToolExecutionError(
"tool_execution_reconciliation_not_supported",
"manual reconciliation is only supported for conditional write_file receipts",
"manual reconciliation is only supported for conditional write_file or image-generation receipts",
)

prior_metadata = (
Expand Down Expand Up @@ -1921,3 +1928,22 @@ async def reconcile_unknown_tool_execution(
execution.completed_at = reconciled_at
await db.flush()
return execution


def is_user_reconcilable_unknown_execution(execution: AgentToolExecution) -> bool:
"""Return whether Direct Chat can safely settle this unknown receipt.

The user must explicitly decide whether a dispatched operation took effect.
A ``not_applied`` decision closes only the old receipt; any retry remains a
new tool call, so the original provider request is never replayed.
"""
effect, retry_policy = _execution_metadata(execution)
return (
execution.tool_name == "write_file"
and effect == "write"
and retry_policy == "conditional"
) or (
execution.tool_name in _IMAGE_GENERATION_TOOL_NAMES
and effect == "external_write"
and retry_policy == "never"
)
53 changes: 53 additions & 0 deletions backend/tests/test_chat_session_runtime_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,59 @@ async def test_runtime_state_exposes_unknown_write_and_blocks_plain_resume() ->
assert pending.can_reconcile is True


@pytest.mark.asyncio
async def test_runtime_state_exposes_unknown_image_generation_for_user_confirmation() -> None:
agent, user, session, run = _records()
reader = SimpleNamespace(get_run_state=AsyncMock(return_value=_view(run)))
execution = AgentToolExecution(
id=uuid.uuid4(),
tenant_id=run.tenant_id,
run_id=run.id,
tool_call_id="call-image-1",
tool_name="generate_image_openai",
assistant_message_id="assistant-1",
arguments_hash="hash",
sanitized_arguments={},
effect="external_write",
retry_policy="never",
attempt_count=1,
status="unknown",
result_summary="The image generation outcome is unknown.",
result_metadata={"error_code": "image_generation_outcome_unknown"},
started_at=run.created_at,
completed_at=run.updated_at,
)
db = _Session(
_Result(scalar=session),
_Result(values=[run]),
_Result(scalar=None),
_Result(values=[execution]),
_Result(scalar=None),
)

with (
patch(
"app.api.chat_sessions.check_agent_access",
new=AsyncMock(return_value=(agent, None)),
),
patch(
"app.api.chat_sessions._open_run_state_reader",
return_value=_ReaderContext(reader),
),
):
response = await get_session_runtime_state(
agent.id,
session.id,
current_user=user,
db=db, # type: ignore[arg-type]
)

assert response.active_run is not None
assert response.active_run.can_resume is False
assert response.active_run.pending_tool_reconciliations[0].tool_name == "generate_image_openai"
assert response.active_run.pending_tool_reconciliations[0].can_reconcile is True


@pytest.mark.asyncio
async def test_runtime_state_disables_resume_and_cancel_while_cancel_is_inflight() -> None:
agent, user, session, run = _records()
Expand Down
20 changes: 15 additions & 5 deletions backend/tests/test_tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,19 @@ def _sql(statement) -> str:
("succeeded", "externally_confirmed_applied"),
],
)
async def test_unknown_conditional_write_can_be_reconciled_by_user(
@pytest.mark.parametrize(
("tool_name", "effect", "retry_policy"),
[
("write_file", "write", "conditional"),
("generate_image_openai", "external_write", "never"),
],
)
async def test_user_reconcilable_unknown_receipt_can_be_settled(
confirmed_status: str,
expected_error_code: str,
tool_name: str,
effect: str,
retry_policy: str,
) -> None:
tenant_id = uuid.uuid4()
run_id = uuid.uuid4()
Expand All @@ -192,10 +202,10 @@ async def test_unknown_conditional_write_can_be_reconciled_by_user(
tenant_id=tenant_id,
run_id=run_id,
status="unknown",
effect="write",
retry_policy="conditional",
effect=effect,
retry_policy=retry_policy,
)
execution.tool_name = "write_file"
execution.tool_name = tool_name
execution.completed_at = _NOW
db = _FakeSession(execution)

Expand Down Expand Up @@ -234,7 +244,7 @@ async def test_unknown_reconciliation_rejects_unsupported_tool() -> None:

with pytest.raises(
tool_execution.ToolExecutionError,
match="only supported for conditional write_file",
match="only supported for conditional write_file or image-generation",
):
await tool_execution.reconcile_unknown_tool_execution(
db, # type: ignore[arg-type]
Expand Down