document_redaction / agent-redact /pi /agentcore_runtime.py
seanpedrickcase's picture
Sync: fix on agent task download links with root path
b5355b0
Raw
History Blame Contribute Delete
16.2 kB
"""AgentCore HTTP/SSE client :class:`AgentRuntime` for the Gradio UI."""
from __future__ import annotations
import base64
import json
import os
from collections.abc import Iterator
from typing import Any
from urllib.parse import unquote, urlparse
import httpx
from agent_runtime import AgentRuntime, AgentRuntimeError, AgentStreamEvent
from agentcore_boto import bedrock_agentcore_client, region_from_agentcore_arn
def agentcore_runtime_url() -> str:
raw = (os.environ.get("AGENTCORE_RUNTIME_URL") or "").strip().rstrip("/")
if raw.endswith("/invocations"):
raw = raw[: -len("/invocations")].rstrip("/")
if not raw:
raise AgentRuntimeError(
"AGENTCORE_RUNTIME_URL is not set. Deploy AgentCore runtime or use "
"AGENT_ORCHESTRATOR=pi|langgraph for local orchestration."
)
return raw
def parse_agentcore_runtime_url(url: str) -> tuple[str, str]:
"""Return ``(region, agent_runtime_arn)`` from an AgentCore runtime base URL."""
normalized = (url or "").strip().rstrip("/")
if normalized.endswith("/invocations"):
normalized = normalized[: -len("/invocations")].rstrip("/")
parsed = urlparse(normalized)
host = (parsed.hostname or "").strip()
if not host.startswith("bedrock-agentcore."):
raise AgentRuntimeError(
f"AGENTCORE_RUNTIME_URL must be a bedrock-agentcore HTTPS URL, got: {url!r}"
)
region = host.removeprefix("bedrock-agentcore.").split(".", 1)[0].strip()
if not region:
raise AgentRuntimeError(
f"Could not parse AWS region from AgentCore URL: {url!r}"
)
prefix = "/runtimes/"
path = parsed.path or ""
if not path.startswith(prefix):
raise AgentRuntimeError(
f"AGENTCORE_RUNTIME_URL must include /runtimes/<arn>, got path: {path!r}"
)
arn = unquote(path[len(prefix) :].strip("/"))
if not arn.startswith("arn:"):
raise AgentRuntimeError(
f"Could not parse runtime ARN from AgentCore URL: {url!r}"
)
return region_from_agentcore_arn(arn, resource_label="runtime"), arn
def _agentcore_api_key() -> str:
return (os.environ.get("AGENTCORE_API_KEY") or "").strip()
def _bedrock_agentcore_client(region: str):
return bedrock_agentcore_client(region)
class AgentCoreAgentRuntime(AgentRuntime):
"""Proxy that streams events from a remote Bedrock AgentCore runtime."""
def __init__(self, *, session_hash: str | None = None) -> None:
self._session_hash = session_hash
self._running = False
self._prompt_stream_depth = 0
self._abort_requested = False
self._pending_ui_notices: list[dict[str, Any]] = []
self._pending_ui_history: list[dict[str, Any]] = []
self._pending_workspace_files: list[dict[str, str]] = []
self._sync_workspace_files = True
@property
def orchestrator(self) -> str:
return "agentcore"
@property
def running(self) -> bool:
return self._running
@property
def prompt_stream_active(self) -> bool:
return self._prompt_stream_depth > 0
@property
def abort_requested(self) -> bool:
return self._abort_requested
def start(self) -> None:
agentcore_runtime_url()
self._running = True
def close(self) -> None:
self._running = False
def abort(self) -> None:
self._abort_requested = True
def new_session(self) -> None:
self._abort_requested = False
def set_model(self, provider: str, model_id: str) -> dict[str, Any]:
os.environ["AGENT_DEFAULT_PROVIDER"] = provider
os.environ["AGENT_DEFAULT_MODEL"] = model_id
return {"provider": provider, "model": model_id}
def get_state(self) -> dict[str, Any]:
return {
"isStreaming": self.prompt_stream_active,
"provider": "agentcore",
"model": {"provider": "agentcore", "id": agentcore_runtime_url()},
}
def stage_ui_chat_notice(self, label: str, message: str) -> None:
text = message.strip()
if not text:
return
self._pending_ui_history.append(
{"role": "user", "content": f"_**{label}:**_ {text}"}
)
self._pending_ui_history.append({"role": "assistant", "content": ""})
def drain_pending_ui_history(self) -> list[dict[str, Any]]:
pending = self._pending_ui_history[:]
self._pending_ui_history.clear()
return pending
def stage_workspace_files(self, files: list[dict[str, str]]) -> None:
"""Queue files to upload into the remote AgentCore session workspace on next invoke."""
for item in files:
if not isinstance(item, dict):
continue
relative = str(item.get("relative_path") or item.get("name") or "").strip()
encoded = str(item.get("content_base64") or "").strip()
if relative and encoded:
self._pending_workspace_files.append(
{"relative_path": relative, "content_base64": encoded}
)
def set_sync_workspace_files(self, enabled: bool) -> None:
self._sync_workspace_files = bool(enabled)
def _write_local_workspace_file(
self, relative_path: str, content_base64: str
) -> None:
if not self._session_hash:
return
from session_workspace import session_workspace_dir
root = session_workspace_dir(self._session_hash).resolve()
dest = (root / relative_path).resolve()
try:
dest.relative_to(root)
except ValueError:
return
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(base64.b64decode(content_base64, validate=True))
def prompt_events(self, message: str) -> Iterator[AgentStreamEvent]:
self._prompt_stream_depth += 1
self._abort_requested = False
try:
if not self._running:
self.start()
payload: dict[str, Any] = {
"prompt": message,
"session_hash": self._session_hash or "",
}
if self._pending_workspace_files:
payload["workspace_files"] = self._pending_workspace_files[:]
self._pending_workspace_files.clear()
if self._sync_workspace_files:
payload["sync_workspace_files"] = True
from agentcore_workspace_bridge import build_agentcore_invoke_runtime_config
runtime_config = build_agentcore_invoke_runtime_config()
if runtime_config:
payload["runtime_config"] = runtime_config
yield AgentStreamEvent(kind="status", text="AgentCore runtime started…")
if _agentcore_api_key():
yield from self._prompt_events_httpx(payload)
else:
yield from self._prompt_events_boto3(payload)
yield AgentStreamEvent(kind="done", text="Agent finished.")
except httpx.HTTPError as exc:
raise AgentRuntimeError(str(exc)) from exc
finally:
self._prompt_stream_depth = max(0, self._prompt_stream_depth - 1)
def _prompt_events_httpx(
self, payload: dict[str, Any]
) -> Iterator[AgentStreamEvent]:
url = f"{agentcore_runtime_url()}/invocations"
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Authorization": f"Bearer {_agentcore_api_key()}",
}
timeout = httpx.Timeout(connect=30.0, read=1800.0, write=30.0, pool=30.0)
with httpx.Client(timeout=timeout) as client:
with client.stream("POST", url, json=payload, headers=headers) as response:
response.raise_for_status()
yield from self._iter_sse_response(response.iter_lines())
def _prompt_events_boto3(
self, payload: dict[str, Any]
) -> Iterator[AgentStreamEvent]:
from botocore.exceptions import ClientError
region, runtime_arn = parse_agentcore_runtime_url(agentcore_runtime_url())
client = _bedrock_agentcore_client(region)
body = json.dumps(payload).encode("utf-8")
try:
response = client.invoke_agent_runtime(
agentRuntimeArn=runtime_arn,
payload=body,
contentType="application/json",
accept="text/event-stream",
)
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code", "")
message = exc.response.get("Error", {}).get("Message", str(exc))
hint = (
" Ensure your IAM identity has bedrock-agentcore:InvokeAgentRuntime on "
f"{runtime_arn}."
)
if "initialization" in message.lower() or code == "RuntimeClientError":
hint += (
" The runtime container failed to start (import error or slow init). "
"Check CloudWatch log group "
f"/aws/bedrock-agentcore/runtimes/{runtime_arn.rsplit('/', 1)[-1]}/"
" then re-run package_runtime.py and agentcore deploy."
)
raise AgentRuntimeError(f"{code}: {message}.{hint}") from exc
status_code = int(response.get("statusCode") or 200)
if status_code >= 400:
raise AgentRuntimeError(
f"AgentCore invoke failed with HTTP {status_code} for runtime {runtime_arn}."
)
stream = response.get("response")
if stream is None:
return
if hasattr(stream, "iter_lines"):
yield from self._iter_sse_response(
(
line.decode("utf-8", errors="replace")
if isinstance(line, (bytes, bytearray))
else str(line)
)
for line in stream.iter_lines()
)
return
raw = stream.read() if hasattr(stream, "read") else stream
if isinstance(raw, str):
raw_bytes = raw.encode("utf-8")
else:
raw_bytes = bytes(raw or b"")
content_type = str(response.get("contentType") or "").lower()
if "event-stream" in content_type or raw_bytes.strip().startswith(b"data:"):
yield from self._iter_sse_lines(
line.decode("utf-8", errors="replace")
for line in raw_bytes.splitlines()
)
else:
yield from self._iter_json_response(raw_bytes)
def _iter_sse_response(self, lines: Iterator[str]) -> Iterator[AgentStreamEvent]:
for line in lines:
if self._abort_requested:
yield AgentStreamEvent(kind="done", text="Agent aborted.")
return
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
continue
try:
event = json.loads(data)
except json.JSONDecodeError:
yield AgentStreamEvent(kind="text_delta", text=data)
continue
yield from self._map_agentcore_event(event)
def _iter_sse_lines(self, lines: Iterator[str]) -> Iterator[AgentStreamEvent]:
yield from self._iter_sse_response(lines)
def _iter_json_response(self, raw: bytes) -> Iterator[AgentStreamEvent]:
if not raw.strip():
return
try:
payload = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
yield AgentStreamEvent(
kind="text_snapshot", text=raw.decode("utf-8", errors="replace")
)
return
if isinstance(payload, dict):
if payload.get("type") == "error":
yield AgentStreamEvent(
kind="error",
text=str(payload.get("message") or "AgentCore error"),
is_error=True,
)
return
if "result" in payload:
yield AgentStreamEvent(
kind="text_snapshot", text=str(payload["result"])
)
return
yield AgentStreamEvent(
kind="text_snapshot",
text=json.dumps(payload, default=str),
)
else:
yield AgentStreamEvent(kind="text_snapshot", text=str(payload))
def _map_agentcore_event(self, event: dict[str, Any]) -> Iterator[AgentStreamEvent]:
event_type = str(event.get("type") or "")
if event_type == "agent_start":
yield AgentStreamEvent(kind="status", text="Agent started…")
elif event_type == "agent_end":
yield AgentStreamEvent(
kind="status", text=str(event.get("message") or "Agent finished.")
)
elif event_type == "status":
yield AgentStreamEvent(kind="status", text=str(event.get("message") or ""))
elif event_type == "error":
yield AgentStreamEvent(
kind="error",
text=str(event.get("message") or "AgentCore error"),
is_error=True,
)
elif event_type == "workspace_file":
relative = str(event.get("relative_path") or "").strip()
encoded = str(event.get("content_base64") or "").strip()
if relative and encoded:
try:
self._write_local_workspace_file(relative, encoded)
yield AgentStreamEvent(
kind="status",
text=f"Downloaded `{relative}` from AgentCore workspace.",
)
if relative.lower().endswith("_redacted.pdf"):
yield AgentStreamEvent(kind="workspace_sync")
except (OSError, ValueError) as exc:
yield AgentStreamEvent(
kind="status",
text=f"Could not save `{relative}` locally: {exc}",
)
elif event_type == "message_update":
role = str(event.get("role") or "")
if role == "tool":
tool_name = str(event.get("tool_name") or "tool")
content = event.get("content")
output = (
content
if isinstance(content, str)
else json.dumps(content, default=str)
)
is_error = "error" in output.lower() or "not found" in output.lower()
yield AgentStreamEvent(
kind="tool_end",
tool_name=tool_name,
tool_output=output,
is_error=is_error,
)
else:
content = event.get("content")
text = (
content
if isinstance(content, str)
else json.dumps(content, default=str)
)
tool_calls = event.get("tool_calls") or []
if isinstance(tool_calls, list):
for call in tool_calls:
if not isinstance(call, dict):
continue
name = str(call.get("name") or "tool")
args = (
call.get("args")
if isinstance(call.get("args"), dict)
else {}
)
yield AgentStreamEvent(
kind="tool_start",
tool_name=name,
tool_args=args,
text=name,
)
if text.strip():
yield AgentStreamEvent(kind="text_snapshot", text=text)
else:
yield AgentStreamEvent(
kind="status", text=json.dumps(event, default=str)[:500]
)