chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeAlias, cast
|
||||
|
||||
from openai.types.responses import (
|
||||
ResponseComputerToolCall,
|
||||
ResponseFileSearchToolCall,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionWebSearch,
|
||||
)
|
||||
from openai.types.responses.response_code_interpreter_tool_call import (
|
||||
ResponseCodeInterpreterToolCall,
|
||||
)
|
||||
from openai.types.responses.response_output_item import ImageGenerationCall, LocalShellCall, McpCall
|
||||
from pydantic import BaseModel, Field
|
||||
from rich import box
|
||||
from rich.console import Console, Group
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.pretty import Pretty
|
||||
from rich.prompt import Prompt
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from agents import ItemHelpers, TResponseInputItem
|
||||
from agents.items import (
|
||||
CompactionItem,
|
||||
HandoffCallItem,
|
||||
HandoffOutputItem,
|
||||
MCPApprovalRequestItem,
|
||||
MCPApprovalResponseItem,
|
||||
MCPListToolsItem,
|
||||
MessageOutputItem,
|
||||
ReasoningItem,
|
||||
ToolApprovalItem,
|
||||
ToolCallItem,
|
||||
ToolCallOutputItem,
|
||||
ToolSearchCallItem,
|
||||
ToolSearchOutputItem,
|
||||
)
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.sandbox.session import BaseSandboxClient, SandboxSession
|
||||
from agents.stream_events import (
|
||||
AgentUpdatedStreamEvent,
|
||||
RawResponsesStreamEvent,
|
||||
StreamEvent,
|
||||
)
|
||||
from examples.auto_mode import input_with_fallback, is_auto_mode
|
||||
|
||||
DEFAULT_SANDBOX_IMAGE = "sandbox-tutorials:latest"
|
||||
console = Console()
|
||||
PanelBody = Group | Pretty | Text
|
||||
PrintableEvent: TypeAlias = StreamEvent | str
|
||||
SandboxClient: TypeAlias = BaseSandboxClient[Any]
|
||||
InteractiveTurnRunner: TypeAlias = Callable[
|
||||
[list[TResponseInputItem]], Awaitable[list[TResponseInputItem]]
|
||||
]
|
||||
|
||||
|
||||
class ApplyPatchOperationPayload(TypedDict):
|
||||
path: str
|
||||
type: Literal["create_file", "update_file", "delete_file"]
|
||||
diff: str
|
||||
|
||||
|
||||
class ApplyPatchCallPayload(TypedDict):
|
||||
type: Literal["apply_patch_call"]
|
||||
call_id: str
|
||||
operation: ApplyPatchOperationPayload
|
||||
|
||||
|
||||
class Question(BaseModel):
|
||||
query: str = Field(description="User-facing question to ask.")
|
||||
options: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Suggested answer options. The UI always adds a custom free-text choice.",
|
||||
)
|
||||
|
||||
|
||||
class QuestionAnswer(BaseModel):
|
||||
question: str = Field(description="The question that was asked.")
|
||||
answer: str = Field(description="The user's selected or free-text answer.")
|
||||
|
||||
|
||||
def load_env_defaults(env_path: Path) -> None:
|
||||
if not env_path.exists():
|
||||
return
|
||||
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
normalized_key = key.strip()
|
||||
normalized_value = value.strip().strip('"').strip("'")
|
||||
if normalized_key:
|
||||
os.environ.setdefault(normalized_key, normalized_value)
|
||||
|
||||
|
||||
async def create_sandbox_client_and_session(
|
||||
*,
|
||||
manifest: Manifest,
|
||||
use_docker: bool,
|
||||
image: str = DEFAULT_SANDBOX_IMAGE,
|
||||
) -> tuple[SandboxClient, SandboxSession]:
|
||||
if use_docker:
|
||||
try:
|
||||
from docker import from_env as docker_from_env # type: ignore[import-untyped]
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"Docker-backed runs require the Docker SDK. Install repo dependencies with `make sync`."
|
||||
) from exc
|
||||
|
||||
client: SandboxClient = DockerSandboxClient(
|
||||
docker_from_env(environment=build_docker_environment())
|
||||
)
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=DockerSandboxClientOptions(image=image),
|
||||
)
|
||||
return client, sandbox
|
||||
|
||||
client = UnixLocalSandboxClient()
|
||||
sandbox = await client.create(manifest=manifest)
|
||||
return client, sandbox
|
||||
|
||||
|
||||
def build_docker_environment() -> dict[str, str]:
|
||||
environment = os.environ.copy()
|
||||
if environment.get("DOCKER_HOST") or environment.get("DOCKER_CONTEXT"):
|
||||
return environment
|
||||
|
||||
# Respect whichever Docker context the CLI is currently using, including Docker Desktop
|
||||
# and Colima, without taking a direct dependency on a specific daemon provider.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "context", "inspect", "--format", "{{json .Endpoints.docker.Host}}"],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
docker_host = json.loads(result.stdout.strip() or "null")
|
||||
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
||||
return environment
|
||||
|
||||
if isinstance(docker_host, str) and docker_host:
|
||||
environment["DOCKER_HOST"] = docker_host
|
||||
return environment
|
||||
|
||||
|
||||
def prompt_with_fallback(prompt: str, fallback: str) -> str:
|
||||
if is_auto_mode():
|
||||
return input_with_fallback(prompt, fallback).strip()
|
||||
|
||||
try:
|
||||
return Prompt.ask(prompt).strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return fallback
|
||||
|
||||
|
||||
def ask_user_questions(questions: list[Question]) -> list[QuestionAnswer]:
|
||||
answers: list[QuestionAnswer] = []
|
||||
|
||||
for question_index, question in enumerate(questions, start=1):
|
||||
suggested_options = [option.strip() for option in question.options if option.strip()]
|
||||
custom_choice_index = len(suggested_options) + 1
|
||||
options_text = Text.from_markup(
|
||||
"\n".join(
|
||||
[
|
||||
*(
|
||||
f"[cyan]{index}.[/cyan] {option}"
|
||||
for index, option in enumerate(
|
||||
suggested_options,
|
||||
start=1,
|
||||
)
|
||||
),
|
||||
f"[cyan]{custom_choice_index}.[/cyan] Use your own text",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Group(
|
||||
Text(question.query),
|
||||
options_text,
|
||||
),
|
||||
title=f"Question {question_index}",
|
||||
border_style="magenta",
|
||||
box=box.ROUNDED,
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
choice = prompt_with_fallback(
|
||||
f"[bold cyan]Select[/bold cyan] 1-{custom_choice_index}",
|
||||
"1" if suggested_options else str(custom_choice_index),
|
||||
)
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(suggested_options):
|
||||
answer = suggested_options[int(choice) - 1]
|
||||
break
|
||||
if choice.isdigit() and int(choice) == custom_choice_index:
|
||||
answer = prompt_with_fallback(
|
||||
"[bold cyan]Your answer[/bold cyan]",
|
||||
suggested_options[0] if suggested_options else "Use a conservative assumption.",
|
||||
)
|
||||
if answer:
|
||||
break
|
||||
continue
|
||||
if choice and not choice.isdigit():
|
||||
answer = choice
|
||||
break
|
||||
|
||||
console.print(
|
||||
f"[red]Please enter a number from 1 to {custom_choice_index}, or custom text.[/red]"
|
||||
)
|
||||
|
||||
answers.append(QuestionAnswer(question=question.query, answer=answer))
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Pretty([answer.model_dump(mode="json") for answer in answers], expand_all=True),
|
||||
title="Question answers",
|
||||
border_style="magenta",
|
||||
box=box.ROUNDED,
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return answers
|
||||
|
||||
|
||||
async def run_interactive_loop(
|
||||
*,
|
||||
conversation: list[TResponseInputItem],
|
||||
no_interactive: bool,
|
||||
run_turn: InteractiveTurnRunner,
|
||||
) -> list[TResponseInputItem]:
|
||||
if no_interactive or is_auto_mode():
|
||||
return conversation
|
||||
|
||||
console.print("[dim]Enter follow-up prompts. Press Ctrl-D or Ctrl-C to finish.[/dim]")
|
||||
while True:
|
||||
try:
|
||||
next_message = Prompt.ask("[bold cyan]user[/bold cyan]").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
|
||||
if not next_message:
|
||||
continue
|
||||
|
||||
conversation.append({"role": "user", "content": next_message})
|
||||
conversation = await run_turn(conversation)
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
def print_event(event: PrintableEvent) -> None:
|
||||
if isinstance(event, str):
|
||||
console.print()
|
||||
console.rule("[bold green]Final output[/bold green]", style="green")
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(event or "_No final output returned._"),
|
||||
border_style="green",
|
||||
box=box.ROUNDED,
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, AgentUpdatedStreamEvent):
|
||||
console.print(
|
||||
Panel(
|
||||
Pretty(event.new_agent.name, expand_all=True),
|
||||
title="Agent updated",
|
||||
border_style="cyan",
|
||||
box=box.ROUNDED,
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(event, RawResponsesStreamEvent):
|
||||
return
|
||||
|
||||
body: PanelBody
|
||||
match event.item:
|
||||
case ReasoningItem() as item:
|
||||
body = Pretty(item, expand_all=True)
|
||||
title = f"Reasoning item: {event.name.replace('_', ' ')}"
|
||||
case ToolCallItem() as item:
|
||||
tool_name = "tool"
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
match item.raw_item:
|
||||
case ResponseFunctionToolCall() as raw_item:
|
||||
tool_name = raw_item.name
|
||||
payload = json.loads(raw_item.arguments) if raw_item.arguments else {}
|
||||
if tool_name == "exec_command":
|
||||
command = payload["cmd"]
|
||||
if "\\n" in command and "\n" not in command:
|
||||
command = command.replace("\\n", "\n")
|
||||
body = Group(
|
||||
Pretty(
|
||||
{key: value for key, value in payload.items() if key != "cmd"},
|
||||
expand_all=True,
|
||||
),
|
||||
Syntax(command, "bash", theme="ansi_dark", word_wrap=True),
|
||||
)
|
||||
else:
|
||||
body = Pretty(payload, expand_all=True)
|
||||
case ResponseComputerToolCall() as raw_item:
|
||||
tool_name = "computer"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case ResponseFileSearchToolCall() as raw_item:
|
||||
tool_name = "file_search"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case ResponseFunctionWebSearch() as raw_item:
|
||||
tool_name = "web_search"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case McpCall() as raw_item:
|
||||
tool_name = "mcp"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case ResponseCodeInterpreterToolCall() as raw_item:
|
||||
tool_name = "code_interpreter"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case ImageGenerationCall() as raw_item:
|
||||
tool_name = "image_generation"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case LocalShellCall() as raw_item:
|
||||
tool_name = "local_shell"
|
||||
body = Pretty(raw_item, expand_all=True)
|
||||
case dict() as raw_item:
|
||||
tool_name = "apply_patch"
|
||||
payload = cast(ApplyPatchCallPayload, raw_item)["operation"]
|
||||
body = Group(
|
||||
Pretty(
|
||||
{
|
||||
"path": payload["path"],
|
||||
"type": payload["type"],
|
||||
},
|
||||
expand_all=True,
|
||||
),
|
||||
Syntax(payload["diff"], "diff", theme="ansi_dark", word_wrap=True),
|
||||
)
|
||||
title = f"Tool call: {tool_name}"
|
||||
case ToolCallOutputItem() as item:
|
||||
body = Text(item.output) if isinstance(item.output, str) else Pretty(item.output)
|
||||
title = "Tool output"
|
||||
case MessageOutputItem() as item:
|
||||
output = ItemHelpers.text_message_output(item)
|
||||
body = Text(output) if isinstance(output, str) else Pretty(output, expand_all=True)
|
||||
title = "Message output"
|
||||
case ToolSearchCallItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "Tool search call"
|
||||
case ToolSearchOutputItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "Tool search output"
|
||||
case HandoffCallItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "Handoff call"
|
||||
case HandoffOutputItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "Handoff output"
|
||||
case MCPListToolsItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "MCP list tools"
|
||||
case MCPApprovalRequestItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "MCP approval request"
|
||||
case MCPApprovalResponseItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "MCP approval response"
|
||||
case CompactionItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "Compaction"
|
||||
case ToolApprovalItem() as item:
|
||||
body = Pretty(item.raw_item, expand_all=True)
|
||||
title = "Tool approval"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
body,
|
||||
title=title,
|
||||
border_style="cyan",
|
||||
box=box.ROUNDED,
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user