ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
"""Fail-closed LLM planning and dialogue contracts for Phone Agent add-on.
|
||||
|
||||
The direct arm receives a fixed call plan. The ReAct arm asks a real external
|
||||
OpenAI-compatible provider to observe an incomplete task, identify missing facts,
|
||||
and choose the browser-call action. Both arms use the same external model for the
|
||||
post-ASR dialogue turn. Provider errors are surfaced; this module has no local
|
||||
planner, parser, mock, or fallback path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
DEFAULT_ARK_MODEL = "doubao-seed-1-6-flash-250615"
|
||||
ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallPlan:
|
||||
mode: str
|
||||
callee_name: str
|
||||
goal: str
|
||||
context: str
|
||||
instructions: str
|
||||
opening_line: str
|
||||
missing_information: list[str] = field(default_factory=list)
|
||||
trace: list[dict[str, str]] = field(default_factory=list)
|
||||
planner_model: str | None = None
|
||||
planner_receipt: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfig:
|
||||
name: str
|
||||
api_key: str
|
||||
base_url: str | None
|
||||
model: str
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(label: str, value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
raise ValueError(f"{label} is required")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _redact_secrets(value: Any) -> Any:
|
||||
"""Remove credential values before any provider request/response is retained."""
|
||||
serialized = json.dumps(value, ensure_ascii=False, default=str)
|
||||
for name, secret in os.environ.items():
|
||||
if (
|
||||
any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
|
||||
and len(secret) >= 8
|
||||
):
|
||||
serialized = serialized.replace(secret, "[REDACTED]")
|
||||
serialized = re.sub(r"\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b", "[REDACTED]", serialized)
|
||||
return json.loads(serialized)
|
||||
|
||||
|
||||
def _provider_config(model: str | None = None) -> ProviderConfig:
|
||||
provider = os.getenv("PHONE_MODEL_PROVIDER", "ark").casefold()
|
||||
if provider == "ark":
|
||||
key = os.getenv("ARK_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER=ark requires ARK_API_KEY")
|
||||
return ProviderConfig(
|
||||
name="ark",
|
||||
api_key=key,
|
||||
base_url=os.getenv("ARK_BASE_URL", ARK_BASE_URL),
|
||||
model=model or os.getenv("PHONE_PLANNER_MODEL", DEFAULT_ARK_MODEL),
|
||||
)
|
||||
if provider == "openai":
|
||||
key = os.getenv("OPENAI_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER=openai requires OPENAI_API_KEY")
|
||||
return ProviderConfig(
|
||||
name="openai",
|
||||
api_key=key,
|
||||
base_url=os.getenv("OPENAI_BASE_URL") or None,
|
||||
model=model or os.getenv("PHONE_PLANNER_MODEL", "gpt-4.1-mini"),
|
||||
)
|
||||
if provider == "openrouter":
|
||||
key = os.getenv("OPENROUTER_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER=openrouter requires OPENROUTER_API_KEY")
|
||||
return ProviderConfig(
|
||||
name="openrouter",
|
||||
api_key=key,
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model=model or os.getenv("PHONE_PLANNER_MODEL", "openai/gpt-4.1-mini"),
|
||||
)
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER must be ark, openai, or openrouter")
|
||||
|
||||
|
||||
def _json_object(text: str) -> dict[str, Any]:
|
||||
value = json.loads(text)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("model response must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _real_json_completion(
|
||||
*,
|
||||
purpose: str,
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
client: OpenAI | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Make one real completion and retain a credential-free raw receipt."""
|
||||
config = _provider_config(model)
|
||||
active_client = client or OpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
request = {
|
||||
"model": model or config.model,
|
||||
"messages": messages,
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0,
|
||||
"max_tokens": 700,
|
||||
}
|
||||
sanitized_request = _redact_secrets(request)
|
||||
started_at = _now()
|
||||
started = time.monotonic()
|
||||
response = active_client.chat.completions.create(**request)
|
||||
latency = time.monotonic() - started
|
||||
finished_at = _now()
|
||||
|
||||
if not response.id:
|
||||
raise RuntimeError(f"{purpose} response omitted its provider response ID")
|
||||
if not response.choices:
|
||||
raise RuntimeError(f"{purpose} response contained no choices")
|
||||
choice = response.choices[0]
|
||||
content = (choice.message.content or "").strip()
|
||||
if not content:
|
||||
raise RuntimeError(f"{purpose} response contained no text")
|
||||
if not choice.finish_reason:
|
||||
raise RuntimeError(f"{purpose} response omitted finish status")
|
||||
usage = response.usage.model_dump(exclude_none=True) if response.usage else None
|
||||
if not usage or int(usage.get("total_tokens", 0)) <= 0:
|
||||
raise RuntimeError(f"{purpose} response omitted token usage")
|
||||
|
||||
raw_response = _redact_secrets(response.model_dump(exclude_none=True))
|
||||
parsed = _json_object(content)
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"purpose": purpose,
|
||||
"execution": "real_external_llm",
|
||||
"provider": provider_name or config.name,
|
||||
"requested_model": request["model"],
|
||||
"provider_model": response.model,
|
||||
"provider_response_id": response.id,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"usage": usage,
|
||||
"started_at_utc": started_at,
|
||||
"finished_at_utc": finished_at,
|
||||
"latency_seconds": round(latency, 6),
|
||||
"request": sanitized_request,
|
||||
"request_sha256": _sha256_json(sanitized_request),
|
||||
"raw_response": raw_response,
|
||||
"raw_response_sha256": _sha256_json(raw_response),
|
||||
"response_content": content,
|
||||
"response_content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
"external_request_completed": True,
|
||||
"mock": False,
|
||||
"probe_only": False,
|
||||
"fallback_used": False,
|
||||
"credential_fields_retained": False,
|
||||
}
|
||||
return parsed, receipt
|
||||
|
||||
|
||||
def direct_plan(
|
||||
*,
|
||||
callee_name: str,
|
||||
goal: str,
|
||||
context: str,
|
||||
instructions: str,
|
||||
) -> CallPlan:
|
||||
"""Build the fixed-parameter control without an LLM planning call."""
|
||||
callee = _required("callee_name", callee_name)
|
||||
return CallPlan(
|
||||
mode="direct",
|
||||
callee_name=callee,
|
||||
goal=_required("goal", goal),
|
||||
context=_required("context", context),
|
||||
instructions=_required("instructions", instructions),
|
||||
opening_line=(
|
||||
f"Hello {callee}. Please state the exact appointment time and confirmation code, "
|
||||
"then explicitly confirm both."
|
||||
),
|
||||
trace=[
|
||||
{"stage": "observation", "summary": "Caller supplied all call parameters."},
|
||||
{
|
||||
"stage": "action",
|
||||
"summary": "Open a WebRTC voice session with the fixed parameters.",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def react_plan(
|
||||
task: str,
|
||||
*,
|
||||
client: OpenAI | None = None,
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> CallPlan:
|
||||
"""Use a real external LLM to create the ReAct call plan; never fall back."""
|
||||
task = _required("task", task)
|
||||
data, receipt = _real_json_completion(
|
||||
purpose="react_planning",
|
||||
client=client,
|
||||
model=model,
|
||||
provider_name=provider_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Plan a local browser WebRTC voice call. Observe the user's task, identify every missing "
|
||||
"task-critical fact, reason briefly about what must be collected, and choose the call action. "
|
||||
"Never invent facts. Return only JSON with callee_name, goal, context, instructions, "
|
||||
"opening_line, missing_information (array), and decision_summary. opening_line must ask aloud "
|
||||
"for the missing appointment time and confirmation code. instructions must require the voice "
|
||||
"Agent to repeat the facts, obtain explicit confirmation, and complete_task only with confirmed "
|
||||
"values. This local experiment records a confirmation but performs no external booking."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": task},
|
||||
],
|
||||
)
|
||||
missing = data.get("missing_information")
|
||||
if (
|
||||
not isinstance(missing, list)
|
||||
or not missing
|
||||
or not all(isinstance(item, str) and item.strip() for item in missing)
|
||||
):
|
||||
raise ValueError("ReAct planner must return a non-empty missing_information string array")
|
||||
decision = _required("decision_summary", str(data.get("decision_summary", "")))
|
||||
return CallPlan(
|
||||
mode="react",
|
||||
callee_name=_required("callee_name", str(data.get("callee_name", ""))),
|
||||
goal=_required("goal", str(data.get("goal", ""))),
|
||||
context=_required("context", str(data.get("context", ""))),
|
||||
instructions=_required("instructions", str(data.get("instructions", ""))),
|
||||
opening_line=_required("opening_line", str(data.get("opening_line", ""))),
|
||||
missing_information=[item.strip() for item in missing],
|
||||
trace=[
|
||||
{"stage": "observation", "summary": task},
|
||||
{"stage": "reason", "summary": decision},
|
||||
{
|
||||
"stage": "action",
|
||||
"summary": "Open a WebRTC call and collect the missing facts by voice.",
|
||||
},
|
||||
],
|
||||
planner_model=f"{receipt['provider']}:{receipt['provider_model']}",
|
||||
planner_receipt=receipt,
|
||||
)
|
||||
|
||||
|
||||
def conversation_turn(
|
||||
plan: CallPlan,
|
||||
transcript: list[dict[str, Any]],
|
||||
user_text: str,
|
||||
*,
|
||||
client: OpenAI | None = None,
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Use the ASR transcript in one real dialogue/completion call; never fall back."""
|
||||
user_text = _required("ASR transcript", user_text)
|
||||
dialogue_model = model or os.getenv("PHONE_DIALOGUE_MODEL")
|
||||
data, receipt = _real_json_completion(
|
||||
purpose="post_asr_dialogue",
|
||||
client=client,
|
||||
model=dialogue_model,
|
||||
provider_name=provider_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are the voice Agent in a short local browser call. The user text below came only from ASR "
|
||||
"over the browser microphone RTP track. Return only JSON with assistant_message, "
|
||||
"explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing "
|
||||
"result, appointment_time, confirmation_number, notes. If the user states an exact time, a "
|
||||
"confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious "
|
||||
"spoken code words/digits into a concise code, and repeat both details in assistant_message. "
|
||||
"Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an "
|
||||
"external action occurred. For a completed turn, completion.result must be exactly "
|
||||
"'Local confirmation recorded.' and completion.notes must be exactly "
|
||||
"'No external organization was contacted or booking made.' "
|
||||
f"Goal: {plan.goal}\nContext: {plan.context}\nInstructions: {plan.instructions}"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": _canonical_json(
|
||||
{
|
||||
"prior_audio_transcript": transcript,
|
||||
"latest_user_asr_transcript": user_text,
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
completion = data.get("completion")
|
||||
required = {"result", "appointment_time", "confirmation_number", "notes"}
|
||||
if not isinstance(completion, dict) or not required.issubset(completion):
|
||||
raise ValueError("dialogue completion object is incomplete")
|
||||
assistant_message = _required("assistant_message", str(data.get("assistant_message", "")))
|
||||
should_complete = data.get("should_complete") is True
|
||||
explicit = data.get("explicit_confirmation_observed") is True
|
||||
if should_complete and not explicit:
|
||||
raise ValueError("model attempted completion without explicit confirmation")
|
||||
if should_complete and (
|
||||
not str(completion.get("appointment_time") or "").strip()
|
||||
or not str(completion.get("confirmation_number") or "").strip()
|
||||
):
|
||||
raise ValueError("model attempted completion without both critical fields")
|
||||
if should_complete and (
|
||||
str(completion.get("result", "")).strip() != "Local confirmation recorded."
|
||||
or str(completion.get("notes", "")).strip()
|
||||
!= "No external organization was contacted or booking made."
|
||||
):
|
||||
raise ValueError(
|
||||
"model attempted completion without the required no-external-action boundary"
|
||||
)
|
||||
return {
|
||||
"assistant_message": assistant_message,
|
||||
"explicit_confirmation_observed": explicit,
|
||||
"should_complete": should_complete,
|
||||
"completion": {key: str(completion.get(key, "")).strip() for key in sorted(required)},
|
||||
"dialogue_model": f"{receipt['provider']}:{receipt['provider_model']}",
|
||||
"llm_receipt": receipt,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CallPlan",
|
||||
"conversation_turn",
|
||||
"direct_plan",
|
||||
"react_plan",
|
||||
]
|
||||
Reference in New Issue
Block a user