Skip to content
Draft
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
2 changes: 2 additions & 0 deletions backend/app/services/agent_runtime/chat_intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ async def enqueue_chat_runtime(
display_content=display_content,
file_name=file_name,
)
confirmation_text = (display_content or content).strip()
resumed_run: AgentRun | None = None
if resume_run_id is not None:
resumed_run = await _require_resume_run(
Expand Down Expand Up @@ -642,6 +643,7 @@ async def enqueue_chat_runtime(
"payload": {
"message_id": str(resolved_message_id),
"content": runtime_content,
"confirmation_text": confirmation_text,
},
},
actor_user_id=user.id,
Expand Down
154 changes: 154 additions & 0 deletions backend/app/services/agent_runtime/feishu_approval_authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Ephemeral, receipt-bound authorization for Feishu approval creation."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
import hashlib
import hmac
import json
import secrets


_AUTHORIZATION_KEY = secrets.token_bytes(32)


@dataclass(frozen=True, slots=True)
class FeishuApprovalCreateAuthorization:
"""One Runtime confirmation bound to one live Tool Ledger receipt."""

run_id: str
tool_call_id: str
execution_id: str
lease_owner: str
tenant_id: str
agent_id: str
actor_user_id: str
arguments_hash: str
signature: str


def feishu_approval_create_arguments_hash(
arguments: Mapping[str, object],
) -> str:
encoded = json.dumps(
dict(arguments),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
allow_nan=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()


def _signature(
*,
run_id: str,
tool_call_id: str,
execution_id: str,
lease_owner: str,
tenant_id: str,
agent_id: str,
actor_user_id: str,
arguments_hash: str,
) -> str:
payload = "\n".join(
(
run_id,
tool_call_id,
execution_id,
lease_owner,
tenant_id,
agent_id,
actor_user_id,
arguments_hash,
)
).encode("utf-8")
return hmac.new(_AUTHORIZATION_KEY, payload, hashlib.sha256).hexdigest()


def issue_feishu_approval_create_authorization(
*,
run_id: str,
tool_call_id: str,
execution_id: str,
lease_owner: str,
tenant_id: str,
agent_id: str,
actor_user_id: str,
arguments: Mapping[str, object],
) -> FeishuApprovalCreateAuthorization:
"""Issue a process-local proof after exact consent and reservation."""
arguments_hash = feishu_approval_create_arguments_hash(arguments)
signature = _signature(
run_id=run_id,
tool_call_id=tool_call_id,
execution_id=execution_id,
lease_owner=lease_owner,
tenant_id=tenant_id,
agent_id=agent_id,
actor_user_id=actor_user_id,
arguments_hash=arguments_hash,
)
return FeishuApprovalCreateAuthorization(
run_id=run_id,
tool_call_id=tool_call_id,
execution_id=execution_id,
lease_owner=lease_owner,
tenant_id=tenant_id,
agent_id=agent_id,
actor_user_id=actor_user_id,
arguments_hash=arguments_hash,
signature=signature,
)


def verify_feishu_approval_create_authorization(
authorization: FeishuApprovalCreateAuthorization | None,
*,
run_id: str,
tool_call_id: str,
execution_id: str,
lease_owner: str,
tenant_id: str,
agent_id: str,
actor_user_id: str,
arguments: Mapping[str, object],
) -> bool:
"""Verify a proof against independently supplied current Runtime facts."""
if authorization is None:
return False
arguments_hash = feishu_approval_create_arguments_hash(arguments)
expected_fields = (
run_id,
tool_call_id,
execution_id,
lease_owner,
tenant_id,
agent_id,
actor_user_id,
arguments_hash,
)
actual_fields = (
authorization.run_id,
authorization.tool_call_id,
authorization.execution_id,
authorization.lease_owner,
authorization.tenant_id,
authorization.agent_id,
authorization.actor_user_id,
authorization.arguments_hash,
)
if actual_fields != expected_fields:
return False
expected_signature = _signature(
run_id=run_id,
tool_call_id=tool_call_id,
execution_id=execution_id,
lease_owner=lease_owner,
tenant_id=tenant_id,
agent_id=agent_id,
actor_user_id=actor_user_id,
arguments_hash=arguments_hash,
)
return hmac.compare_digest(authorization.signature, expected_signature)
42 changes: 41 additions & 1 deletion backend/app/services/agent_runtime/node_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,20 @@ def _resume_message_content(resume_value: Mapping[str, JsonValue]) -> str | list
)


def _resume_confirmation_text(
resume_value: Mapping[str, JsonValue],
) -> str | None:
if resume_value.get("resume_type") != "user_input":
return None
payload = resume_value.get("payload")
if not isinstance(payload, Mapping):
return None
confirmation_text = payload.get("confirmation_text")
if not isinstance(confirmation_text, str) or not confirmation_text.strip():
return None
return confirmation_text.strip()[:500]


def _runtime_message_id(context: RuntimeContext, position: str) -> str:
return str(uuid.uuid5(uuid.UUID(context.run_id), position))

Expand Down Expand Up @@ -844,8 +858,25 @@ async def _tool(
context,
(current_call,),
)
pending_calls = (*result.pending_tool_calls, *tail_calls)
resumed_waiting_request = state["lifecycle"].get(
"resumed_waiting_request"
)
discard_tail_calls = (
isinstance(resumed_waiting_request, Mapping)
and resumed_waiting_request.get(
"discard_remaining_tool_calls_on_resume"
)
is True
and resumed_waiting_request.get("tool_call_id")
== current_call.get("id")
)
pending_calls = (
tuple(result.pending_tool_calls)
if discard_tail_calls
else (*result.pending_tool_calls, *tail_calls)
)
lifecycle = dict(state["lifecycle"])
lifecycle.pop("resumed_waiting_request", None)
lifecycle.update(
{
"pending_tool_calls": [dict(call) for call in pending_calls],
Expand Down Expand Up @@ -1086,6 +1117,9 @@ async def _wait(
)
lifecycle = dict(state["lifecycle"])
waiting_status = state["lifecycle"]["status"]
waiting_request = _validate_waiting_request(
cast(JsonObject | None, state["lifecycle"].get("waiting_request"))
)
lifecycle.update(
{
"status": "running",
Expand All @@ -1105,8 +1139,14 @@ async def _wait(
"runtime_input": "resume",
"runtime_run_id": context.run_id,
})
confirmation_text = _resume_confirmation_text(
cast(Mapping[str, JsonValue], resume_value)
)
if confirmation_text is not None:
resume_message["runtime_confirmation_text"] = confirmation_text
pending_calls = _tool_calls(cast(RuntimeLifecycle, lifecycle))
if waiting_status == "waiting_user" and pending_calls:
lifecycle["resumed_waiting_request"] = waiting_request
deferred = lifecycle.get("deferred_resume_messages", [])
if not isinstance(deferred, list) or any(
not isinstance(message, Mapping) for message in deferred
Expand Down
1 change: 1 addition & 0 deletions backend/app/services/agent_runtime/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class RuntimeLifecycle(TypedDict):
pending_group_at: NotRequired[JsonObject | None]
deferred_resume_messages: NotRequired[list[JsonObject]]
waiting_request: NotRequired[JsonObject | None]
resumed_waiting_request: NotRequired[JsonObject]
verification_result: NotRequired[JsonObject | None]
final_answer: NotRequired[str | None]
finish_delivery_intent: NotRequired[JsonObject | None]
Expand Down
4 changes: 4 additions & 0 deletions backend/app/services/agent_runtime/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@
"db_status",
"projection_status",
"provider",
"provider_http_status",
"provider_code",
"provider_msg",
"provider_response_body",
"operation",
"project_id",
"project_name",
Expand Down
4 changes: 4 additions & 0 deletions backend/app/services/agent_runtime/tool_result_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"archive_status",
"archive_error_code",
"provider",
"provider_http_status",
"provider_code",
"provider_msg",
"provider_response_body",
"operation",
"project_id",
"project_name",
Expand Down
Loading