420 lines
14 KiB
Python
420 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Awaitable, Callable
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from agents import (
|
|
Agent,
|
|
AgentHookContext,
|
|
RunContextWrapper,
|
|
RunHooks,
|
|
Runner,
|
|
SQLiteSession,
|
|
Tool,
|
|
gen_trace_id,
|
|
trace,
|
|
)
|
|
from agents.run import RunConfig
|
|
from agents.sandbox import Manifest, SandboxPathGrant, SandboxRunConfig
|
|
from agents.sandbox.entries import Dir, File, LocalDir
|
|
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
|
from agents.tool_context import ToolContext
|
|
from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore
|
|
from examples.sandbox.healthcare_support.models import (
|
|
CaseResolution,
|
|
MemoryRecap,
|
|
ScenarioCase,
|
|
)
|
|
from examples.sandbox.healthcare_support.support_agents import (
|
|
build_orchestrator,
|
|
build_policy_sandbox_agent,
|
|
memory_recap_agent,
|
|
)
|
|
from examples.sandbox.healthcare_support.tools import HealthcareSupportContext
|
|
|
|
EXAMPLE_ROOT = Path(__file__).resolve().parent
|
|
POLICIES_ROOT = EXAMPLE_ROOT / "policies"
|
|
SKILLS_ROOT = EXAMPLE_ROOT / "skills"
|
|
SDK_ROOT = EXAMPLE_ROOT.parents[2]
|
|
CACHE_ROOT = SDK_ROOT / ".cache" / "healthcare_support"
|
|
SESSION_DB_PATH = CACHE_ROOT / "sessions.db"
|
|
DEFAULT_SESSION_ID = "healthcare-support-demo-memory"
|
|
|
|
ApprovalHandler = Callable[[dict[str, Any]], Awaitable[bool]]
|
|
|
|
|
|
class WorkflowHooks(RunHooks[HealthcareSupportContext]):
|
|
async def on_agent_start(
|
|
self,
|
|
context: AgentHookContext[HealthcareSupportContext],
|
|
agent: Agent[HealthcareSupportContext],
|
|
) -> None:
|
|
await context.context.emit("agent_start", agent=agent.name)
|
|
|
|
async def on_agent_end(
|
|
self,
|
|
context: RunContextWrapper[HealthcareSupportContext],
|
|
agent: Agent[HealthcareSupportContext],
|
|
output: Any,
|
|
) -> None:
|
|
await context.context.emit(
|
|
"agent_end",
|
|
agent=agent.name,
|
|
output=_to_jsonable(output),
|
|
)
|
|
|
|
async def on_tool_start(
|
|
self,
|
|
context: RunContextWrapper[HealthcareSupportContext],
|
|
agent: Agent[HealthcareSupportContext],
|
|
tool: Tool,
|
|
) -> None:
|
|
tool_context = cast(ToolContext[HealthcareSupportContext], context)
|
|
await context.context.emit(
|
|
"tool_start",
|
|
agent=agent.name,
|
|
tool=tool.name,
|
|
call_id=tool_context.tool_call_id,
|
|
arguments=tool_context.tool_arguments,
|
|
)
|
|
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[HealthcareSupportContext],
|
|
agent: Agent[HealthcareSupportContext],
|
|
tool: Tool,
|
|
result: object,
|
|
) -> None:
|
|
tool_context = cast(ToolContext[HealthcareSupportContext], context)
|
|
await context.context.emit(
|
|
"tool_end",
|
|
agent=agent.name,
|
|
tool=tool.name,
|
|
call_id=tool_context.tool_call_id,
|
|
output=_to_jsonable(result),
|
|
)
|
|
|
|
|
|
def _to_jsonable(value: Any) -> Any:
|
|
if isinstance(value, BaseModel):
|
|
return value.model_dump(mode="json")
|
|
if isinstance(value, dict | list | str | int | float | bool) or value is None:
|
|
return value
|
|
try:
|
|
return json.loads(json.dumps(value, default=str))
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def build_context(
|
|
*,
|
|
store: HealthcareSupportDataStore,
|
|
scenario_id: str = "eligibility_verification_basic",
|
|
session_id: str = DEFAULT_SESSION_ID,
|
|
emit_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
|
) -> HealthcareSupportContext:
|
|
return HealthcareSupportContext(
|
|
store=store,
|
|
scenario=store.get_scenario(scenario_id),
|
|
session_id=session_id,
|
|
emit_event=emit_event,
|
|
)
|
|
|
|
|
|
def _build_manifest(scenario: ScenarioCase) -> Manifest:
|
|
return Manifest(
|
|
extra_path_grants=(
|
|
SandboxPathGrant(path=str(POLICIES_ROOT), read_only=True),
|
|
SandboxPathGrant(path=str(SKILLS_ROOT), read_only=True),
|
|
),
|
|
entries={
|
|
"case": Dir(
|
|
children={
|
|
"scenario.json": File(
|
|
content=json.dumps(scenario.model_dump(mode="json"), indent=2).encode(
|
|
"utf-8"
|
|
)
|
|
),
|
|
"transcript.txt": File(content=scenario.transcript.encode("utf-8")),
|
|
},
|
|
description="Synthetic support request and scenario metadata.",
|
|
),
|
|
"policies": LocalDir(
|
|
src=POLICIES_ROOT,
|
|
description="Local healthcare policy and workflow documents.",
|
|
),
|
|
"output": Dir(description="Generated support artifacts for this case."),
|
|
},
|
|
)
|
|
|
|
|
|
async def _structured_tool_output_extractor(result: Any) -> str:
|
|
final_output = result.final_output
|
|
if isinstance(final_output, BaseModel):
|
|
return json.dumps(final_output.model_dump(mode="json"), sort_keys=True)
|
|
return str(final_output)
|
|
|
|
|
|
def _fallback_artifacts(*, scenario: ScenarioCase, resolution: CaseResolution) -> dict[str, str]:
|
|
policy_doc = f"""# Policy Findings
|
|
|
|
## Case
|
|
{scenario.description}
|
|
|
|
## Policy summary
|
|
{resolution.policy_summary}
|
|
|
|
## Next step
|
|
{resolution.next_step}
|
|
"""
|
|
checklist_doc = f"""# Human Review Checklist
|
|
|
|
- Confirm whether the request needs prior authorization for this service and payer.
|
|
- Verify referral state and any missing clinical or billing identifiers.
|
|
- Use this internal summary: {resolution.internal_summary}
|
|
- Patient-facing response: {resolution.patient_facing_response}
|
|
"""
|
|
return {
|
|
"policy_findings.md": policy_doc,
|
|
"human_review_checklist.md": checklist_doc,
|
|
}
|
|
|
|
|
|
async def _copy_output_files(
|
|
*,
|
|
sandbox: Any,
|
|
scenario: ScenarioCase,
|
|
resolution: CaseResolution,
|
|
) -> list[dict[str, str]]:
|
|
scenario_id = scenario.scenario_id
|
|
destination_root = CACHE_ROOT / "output" / scenario_id
|
|
destination_root.mkdir(parents=True, exist_ok=True)
|
|
copied_by_name: dict[str, dict[str, str]] = {}
|
|
|
|
for entry in await sandbox.ls("output"):
|
|
entry_path = Path(entry.path)
|
|
if entry.is_dir():
|
|
continue
|
|
|
|
handle = await sandbox.read(entry_path)
|
|
try:
|
|
payload = handle.read()
|
|
finally:
|
|
handle.close()
|
|
|
|
local_path = destination_root / entry_path.name
|
|
if isinstance(payload, str):
|
|
content = payload
|
|
local_path.write_text(content, encoding="utf-8")
|
|
else:
|
|
content = bytes(payload).decode("utf-8", errors="replace")
|
|
local_path.write_text(content, encoding="utf-8")
|
|
|
|
copied_by_name[entry_path.name] = {
|
|
"name": entry_path.name,
|
|
"path": str(local_path),
|
|
"content": content,
|
|
}
|
|
|
|
for filename, content in _fallback_artifacts(
|
|
scenario=scenario,
|
|
resolution=resolution,
|
|
).items():
|
|
if filename in copied_by_name:
|
|
continue
|
|
local_path = destination_root / filename
|
|
local_path.write_text(content, encoding="utf-8")
|
|
copied_by_name[filename] = {
|
|
"name": filename,
|
|
"path": str(local_path),
|
|
"content": content,
|
|
}
|
|
|
|
return [copied_by_name[name] for name in sorted(copied_by_name)]
|
|
|
|
|
|
async def _resolve_interruptions(
|
|
*,
|
|
result: Any,
|
|
orchestrator: Agent[HealthcareSupportContext],
|
|
context: HealthcareSupportContext,
|
|
conversation_session: SQLiteSession,
|
|
hooks: WorkflowHooks,
|
|
approval_handler: ApprovalHandler | None,
|
|
) -> Any:
|
|
approval_round = 0
|
|
while result.interruptions:
|
|
approval_round += 1
|
|
if approval_round > 5:
|
|
raise RuntimeError("Exceeded 5 approval rounds while resuming the workflow.")
|
|
|
|
state = result.to_state()
|
|
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
|
state_payload = state.to_json(
|
|
context_serializer=lambda value: {
|
|
"scenario_id": value.scenario.scenario_id,
|
|
"session_id": value.session_id,
|
|
"human_handoffs": value.human_handoffs,
|
|
}
|
|
)
|
|
(CACHE_ROOT / "pending_state.json").write_text(
|
|
json.dumps(state_payload, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
for interruption in result.interruptions:
|
|
request = {
|
|
"agent": interruption.agent.name,
|
|
"tool": interruption.name,
|
|
"arguments": _to_jsonable(interruption.arguments),
|
|
}
|
|
await context.emit("human_approval_requested", request=request)
|
|
approved = True if approval_handler is None else await approval_handler(request)
|
|
|
|
if approved:
|
|
context.human_handoff_approved = True
|
|
state.approve(interruption, always_approve=False)
|
|
await context.emit("human_approval_resolved", approved=True, request=request)
|
|
else:
|
|
context.human_handoff_approved = False
|
|
state.reject(interruption)
|
|
await context.emit("human_approval_resolved", approved=False, request=request)
|
|
|
|
result = await Runner.run(
|
|
orchestrator,
|
|
state,
|
|
session=conversation_session,
|
|
hooks=hooks,
|
|
)
|
|
return result
|
|
|
|
|
|
def _workflow_prompt(scenario: ScenarioCase) -> str:
|
|
return json.dumps(
|
|
{
|
|
"scenario_id": scenario.scenario_id,
|
|
"description": scenario.description,
|
|
"transcript": scenario.transcript,
|
|
"patient_metadata": scenario.patient_metadata,
|
|
"followup_answers": scenario.followup_qa,
|
|
},
|
|
indent=2,
|
|
)
|
|
|
|
|
|
async def run_healthcare_support_workflow(
|
|
*,
|
|
context: HealthcareSupportContext,
|
|
scenario_id: str,
|
|
approval_handler: ApprovalHandler | None = None,
|
|
) -> dict[str, Any]:
|
|
scenario = context.store.get_scenario(scenario_id)
|
|
context.scenario = scenario
|
|
context.human_handoffs.clear()
|
|
context.human_handoff_approved = False
|
|
|
|
await context.emit(
|
|
"scenario_loaded",
|
|
scenario_id=scenario.scenario_id,
|
|
description=scenario.description,
|
|
transcript=scenario.transcript,
|
|
)
|
|
|
|
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
|
conversation_session = SQLiteSession(
|
|
session_id=context.session_id or DEFAULT_SESSION_ID, db_path=SESSION_DB_PATH
|
|
)
|
|
await context.emit("memory_ready", session_id=conversation_session.session_id)
|
|
|
|
hooks = WorkflowHooks()
|
|
sandbox_client = UnixLocalSandboxClient()
|
|
sandbox = await sandbox_client.create(manifest=_build_manifest(scenario))
|
|
await context.emit(
|
|
"sandbox_ready",
|
|
backend="unix_local",
|
|
workspace=["case/scenario.json", "case/transcript.txt", "policies/", "output/"],
|
|
)
|
|
|
|
policy_agent = build_policy_sandbox_agent(skills_root=SKILLS_ROOT)
|
|
sandbox_policy_tool = policy_agent.as_tool(
|
|
tool_name="sandbox_policy_packet",
|
|
tool_description="Inspect policy files in a sandbox and generate support artifacts.",
|
|
custom_output_extractor=_structured_tool_output_extractor,
|
|
run_config=RunConfig(
|
|
sandbox=SandboxRunConfig(session=sandbox),
|
|
workflow_name="Healthcare support sandbox packet",
|
|
),
|
|
hooks=hooks,
|
|
max_turns=20,
|
|
)
|
|
orchestrator = build_orchestrator(sandbox_policy_tool=sandbox_policy_tool)
|
|
trace_id = gen_trace_id()
|
|
trace_url = f"https://platform.openai.com/traces/trace?trace_id={trace_id}"
|
|
|
|
try:
|
|
async with sandbox:
|
|
await context.emit("trace_ready", trace_id=trace_id, trace_url=trace_url)
|
|
with trace(
|
|
"Healthcare support workflow",
|
|
trace_id=trace_id,
|
|
group_id=scenario.scenario_id,
|
|
):
|
|
result = await Runner.run(
|
|
orchestrator,
|
|
_workflow_prompt(scenario),
|
|
context=context,
|
|
session=conversation_session,
|
|
hooks=hooks,
|
|
)
|
|
result = await _resolve_interruptions(
|
|
result=result,
|
|
orchestrator=orchestrator,
|
|
context=context,
|
|
conversation_session=conversation_session,
|
|
hooks=hooks,
|
|
approval_handler=approval_handler,
|
|
)
|
|
resolution = result.final_output_as(CaseResolution)
|
|
|
|
copied_files = await _copy_output_files(
|
|
sandbox=sandbox,
|
|
scenario=scenario,
|
|
resolution=resolution,
|
|
)
|
|
await context.emit("artifacts_ready", files=copied_files)
|
|
|
|
memory_result = await Runner.run(
|
|
memory_recap_agent,
|
|
(
|
|
"Summarize what you remember from the session. Include patient, intent, "
|
|
"handoff state, generated files, and next step."
|
|
),
|
|
context=context,
|
|
session=conversation_session,
|
|
hooks=hooks,
|
|
)
|
|
recap = memory_result.final_output_as(MemoryRecap)
|
|
|
|
history_items = await conversation_session.get_items()
|
|
payload = {
|
|
"scenario_id": scenario.scenario_id,
|
|
"description": scenario.description,
|
|
"transcript": scenario.transcript,
|
|
"trace_id": trace_id,
|
|
"trace_url": trace_url,
|
|
"resolution": resolution.model_dump(mode="json"),
|
|
"memory_recap": recap.model_dump(mode="json"),
|
|
"artifacts": copied_files,
|
|
"session_id": conversation_session.session_id,
|
|
"session_memory_items": len(history_items),
|
|
}
|
|
await context.emit("workflow_complete", payload=payload)
|
|
return payload
|
|
finally:
|
|
await sandbox_client.delete(sandbox)
|
|
await context.emit("sandbox_stopped", backend="unix_local")
|