Le résultat d’un agent peut respecter la structure JSON tout en interprétant mal une source. Ce guide donne à un développeur Python un parcours complet, d’un fichier de notes autorisé à un registre d’actions local : résultat producteur typé, contrôles de la source et du sens, approbation humaine liée aux empreintes exactes, puis consommateur qui écrit et relit un rapport Markdown regroupé. Utilisez vos propres notes contenant des engagements explicites. Aucune note réelle ni sortie de modèle n’a servi à préparer ce guide.
Le grand changement
- Ce qui a changé : OpenAI Agents SDK accepte un
output_typePydantic : une application peut donc demander un résultat défini à un agent et le contrôler avant de l’utiliser. Cet exemple ajoute des citations de la source et un contrat versionné autour du résultat. - Pourquoi c’est important : Un développeur reliant un agent à une autre étape de travail peut arrêter une sortie mal formée, incomplète ou sans fondement avant qu’elle devienne une liste d’actions. Un réviseur doit encore juger si les notes citées étayent chaque tâche, responsable et date, et si le modèle a omis des éléments.
- À surveiller : Le point de décision est la frontière du consommateur. Cette recette produit un registre de tri local seulement après l’approbation de l’exécution, de la source et du candidat exacts. Un import ultérieur dans un système de tâches exigerait sa propre autorité, sa politique de reprise et une relecture à destination.
Préparer une source autorisée
Il vous faut Python 3.12, un compte API OpenAI autorisé pour les notes, un réviseur pouvant lire le fichier original et un modèle dont vous avez confirmé la prise en charge actuelle des sorties structurées par schéma JSON dans votre compte. Vérifiez les conditions de traitement des données et les tarifs actuels avant de lancer le producteur. Ne mettez pas de secrets d’accès dans les notes ou le fichier d’approbation. Le seul appel au modèle ci-dessous est produce ; le reste est local.
Enregistrez vos notes originales en UTF-8 dans notes.txt. Elles doivent contenir de véritables engagements explicites de votre propre travail. Attribuez un source_id stable, par exemple votre identifiant de dossier, et gardez les octets du fichier inchangés jusqu’à la revue et à la consommation. Créez candidate/ avant la production ; le fichier candidat doit être nouveau. Prévoyez un répertoire de sortie distinct pour le rapport local ultérieur.
Notez l’empreinte des octets de la source avant l’exécution :
python -c "from pathlib import Path; import hashlib; print(hashlib.sha256(Path('notes.txt').read_bytes()).hexdigest())"Dans un environnement Python 3.12 isolé, créez recipe-requirements.txt avec exactement ces lignes et installez-les :
openai-agents==0.24.0
pydantic==2.14.1python -m pip install -r recipe-requirements.txt
mkdir candidateCes versions se sont installées ensemble dans un environnement Python 3.12.7 local le 20 octobre 2026. Vérifiez leur compatibilité et la disponibilité du modèle au moment de l’utilisation. Enregistrez le script complet ci-dessous sous action_register_recipe.py dans le même répertoire. Il possède deux commandes : produce crée un candidat et un reçu d’exécution ; consume construit le rapport seulement après validation et approbation.
Exécuter le producteur typé
Le contrat Extraction exige la version action-register/v1, le statut ready, partial ou blocked, l’identifiant de source et le SHA-256 des octets exacts du fichier, les actions et un gap_note. Chaque action exige un texte, une citation exacte et des lignes source numérotées à partir de 1, un responsable et une date pouvant être nuls, et complete ou needs_review comme statut de preuve. Un responsable ou une date nuls signifient que les notes ne les précisent pas. Le modèle reçoit l’instruction de ne déduire ni l’un ni l’autre. Tous les champs sont obligatoires et les champs supplémentaires sont rejetés.
Choisissez explicitement le modèle à sortie structurée. Fournissez votre propre chemin autorisé et identifiant :
python action_register_recipe.py produce --source-id OWNED-ID --source notes.txt --candidate candidate/actions.json --model YOUR-STRUCTURED-OUTPUT-MODELLisez candidate/actions.json à côté de candidate/actions.run.json. Ce dernier enregistre l’UUID de l’exécution, les empreintes de la source et du candidat, l’agent final et le modèle demandé. En cas d’échec, un reçu de diagnostic .failure.json portant l’identifiant d’exécution en suffixe est créé. Une interruption, None ou une sortie finale en simple chaîne, ou un agent final inattendu, ne constituent jamais une réussite. Cette commande ne conserve pas l’état reprenable du SDK, RunState ; son reçu d’échec ne peut pas reprendre l’exécution. Examinez la requête, le candidat et tout état en aval avant de décider si une nouvelle exécution convient.
Vérifier le sens et approuver le candidat exact
L’application vérifie les types stricts et les champs obligatoires du candidat, l’identité de la source, le statut prêt, au moins une action complète, les plages de lignes valides, la présence de la citation dans la plage, les tâches ou responsables non vides et les dates canoniques YYYY-MM-DD. Cela prouve la structure et l’emplacement de la citation. Cela ne prouve pas qu’une phrase est réellement un engagement, qu’un nom ou une date signifie ce que le candidat affirme, ni que tous les engagements ont été trouvés.
Lisez les notes originales et chaque action proposée. Comparez le sens, le responsable, la date, la citation et les omissions. Résolvez les ambiguïtés de la source ou du candidat par une nouvelle exécution vérifiée ; ne validez pas les candidats non étayés, partiels ou bloqués. Après avoir vérifié que le fichier inchangé et le candidat correspondent à actions.run.json, rédigez approval.json avec exactement ces sept clés. Copiez l’identifiant et les empreintes du reçu vérifié. Utilisez l’identifiant réel du réviseur et une description substantielle des contrôles réellement effectués :
{
"approved": true,
"run_id": "COPY-RUN-ID",
"source_id": "OWNED-ID",
"source_sha256": "COPY-SOURCE-SHA256",
"payload_sha256": "COPY-PAYLOAD-SHA256",
"reviewer": "YOUR-REVIEWER-ID",
"review_note": "Describe the source, task, owner, date, quotation and omission checks actually performed."
}approved doit être le booléen JSON true, pas une chaîne ni un nombre. Toutes les autres valeurs doivent être des chaînes ; le réviseur et la note ne peuvent pas être vides. Tout champ supplémentaire échoue. L’approbation lie un candidat et une révision de source, pas une future réécriture.
Consommer et relire le rapport
python action_register_recipe.py consume --source-id OWNED-ID --source notes.txt --candidate candidate/actions.json --approval approval.json --out private-outputLe consommateur regroupe les actions acceptées dans Assigned with dates, Needs an owner et Needs a date. Chaque entrée comprend la tâche, l’état du responsable et de la date, la citation et les lignes source. Il écrit action-register-<payload-hash-prefix>.md et un fichier voisin .receipt.json contenant les empreintes de l’exécution, de la source, du candidat et du rapport. Ouvrez les deux fichiers, comparez les actions citées avec les notes et vérifiez report_sha256 par rapport aux octets du fichier Markdown. Un reçu valide indique seulement que le fichier local a été créé et relu ; il n’approuve pas une écriture externe dans un système de tâches.
La commande de consommation affiche les chemins réels du rapport et du reçu. Utilisez-les pour ce contrôle des octets :
python -c "from pathlib import Path; import hashlib,json,sys; report=Path(sys.argv[1]); receipt=json.loads(Path(sys.argv[2]).read_text(encoding='utf-8')); assert hashlib.sha256(report.read_bytes()).hexdigest()==receipt['report_sha256']; print('report hash matches receipt')" PATH-PRINTED-FOR-REPORT PATH-PRINTED-FOR-RECEIPTAprès avoir obtenu un candidat, vérifiez le rejet avec un exemple structurel clairement synthétique. Copiez son JSON dans un fichier distinct, retirez n’importe quel champ obligatoire de sa première action, puis appelez consume avec ce fichier mal formé et un nouveau répertoire de sortie vide. Il doit échouer avant de créer le fichier. Ne traitez jamais cet exemple comme une extraction factuelle et ne l’approuvez pas. Par exemple, ce contrôle local retire evidence_quote d’une copie :
python -c "import json; from pathlib import Path; p=json.loads(Path('candidate/actions.json').read_text(encoding='utf-8')); p['actions'][0].pop('evidence_quote'); Path('candidate/SYNTHETIC-malformed.json').write_text(json.dumps(p), encoding='utf-8')"
python action_register_recipe.py consume --source-id OWNED-ID --source notes.txt --candidate candidate/SYNTHETIC-malformed.json --approval approval.json --out synthetic-rejection-outputLa seconde commande doit échouer et le répertoire de sortie rester absent. N’utilisez pas cet exemple si le vrai candidat n’a pas de première action ; il est déjà incomplet pour ce consommateur. Si une écriture est incertaine, examinez le rapport déterministe et son reçu avant de réessayer. Des octets existants identiques peuvent être réutilisés ; des octets divergents arrêtent l’opération pour rapprochement. Une source ou version de schéma modifiée exige une nouvelle revue. Ne relancez jamais un appel au modèle simplement parce que la réponse du consommateur local était floue.
Recette Python complète
Copiez exactement ce script dans action_register_recipe.py :
"""Private, bounded recipe. No model call occurs unless `produce` is explicitly run."""
import argparse
import hashlib
import json
import uuid
from datetime import date
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr, ValidationError
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class Action(StrictModel):
action_text: StrictStr
evidence_quote: StrictStr
line_start: StrictInt
line_end: StrictInt
owner: StrictStr | None
due_date: StrictStr | None
evidence_status: Literal["complete", "needs_review"]
class Extraction(StrictModel):
schema_version: Literal["action-register/v1"]
status: Literal["ready", "partial", "blocked"]
source_id: StrictStr
source_sha256: StrictStr
actions: list[Action]
gap_note: StrictStr | None
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def canonical(payload: Extraction) -> bytes:
return json.dumps(payload.model_dump(mode="json"), sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def check(payload: Extraction, source_id: str, source: bytes) -> None:
if payload.source_id != source_id or payload.source_sha256 != digest(source):
raise ValueError("source identity or revision mismatch")
lines = source.decode("utf-8").splitlines()
if payload.status == "ready" and (not payload.actions or payload.gap_note is not None):
raise ValueError("ready requires actions and no gap")
if payload.status != "ready":
raise ValueError("partial or blocked result cannot reach consumer")
for index, item in enumerate(payload.actions, 1):
if item.evidence_status != "complete":
raise ValueError(f"action {index}: evidence needs review")
if not item.action_text.strip() or not item.evidence_quote.strip():
raise ValueError(f"action {index}: empty task or evidence")
if not 1 <= item.line_start <= item.line_end <= len(lines):
raise ValueError(f"action {index}: invalid source line range")
segment = "\n".join(lines[item.line_start - 1:item.line_end])
if item.evidence_quote not in segment:
raise ValueError(f"action {index}: quotation absent from source span")
if item.owner is not None and not item.owner.strip():
raise ValueError(f"action {index}: blank owner")
if item.due_date is not None:
try:
if date.fromisoformat(item.due_date).isoformat() != item.due_date:
raise ValueError()
except ValueError as exc:
raise ValueError(f"action {index}: due date must be YYYY-MM-DD") from exc
def report(payload: Extraction) -> str:
"""Application consumer: a triage register grouped by missing ownership/date."""
groups = [
("Assigned with dates", lambda x: x.owner is not None and x.due_date is not None),
("Needs an owner", lambda x: x.owner is None),
("Needs a date", lambda x: x.owner is not None and x.due_date is None),
]
rows = ["# Action register for " + payload.source_id, "", "Review artifact; source meaning was approved separately.", ""]
for heading, predicate in groups:
rows += ["## " + heading, ""]
matches = [(n, a) for n, a in enumerate(payload.actions, 1) if predicate(a)]
for number, action in matches:
rows += [f"{number}. {action.action_text}", f" Owner: {action.owner or 'Unspecified'}; due: {action.due_date or 'Unspecified'}", f" Source lines {action.line_start}-{action.line_end}: {action.evidence_quote}", ""]
if not matches:
rows += ["None.", ""]
return "\n".join(rows)
def load_payload(path: Path) -> Extraction:
return Extraction.model_validate_json(path.read_bytes(), strict=True)
def consume(source_id: str, source_path: Path, payload_path: Path, approval_path: Path, out_dir: Path) -> tuple[Path, Path]:
source = source_path.read_bytes()
payload = load_payload(payload_path)
check(payload, source_id, source)
run = json.loads(payload_path.with_suffix(".run.json").read_text(encoding="utf-8"))
payload_hash = digest(canonical(payload))
if run.get("source_id") != source_id or run.get("source_sha256") != digest(source) or run.get("payload_sha256") != payload_hash or run.get("last_agent") != "Owned-source action extractor" or not str(run.get("run_id", "")).strip():
raise ValueError("producer receipt does not match accepted candidate")
approval = json.loads(approval_path.read_text(encoding="utf-8"))
if (type(approval) is not dict
or set(approval) != {"approved", "run_id", "source_id", "source_sha256", "payload_sha256", "reviewer", "review_note"}
or approval["approved"] is not True
or any(type(approval[key]) is not str for key in ("run_id", "source_id", "source_sha256", "payload_sha256", "reviewer", "review_note"))
or not approval["reviewer"].strip() or not approval["review_note"].strip()
or approval["run_id"] != run["run_id"]
or approval["source_id"] != source_id
or approval["source_sha256"] != digest(source)
or approval["payload_sha256"] != payload_hash):
raise ValueError("human source/meaning approval must bind exact source and payload hashes")
body = report(payload).encode("utf-8")
stem = "action-register-" + payload_hash[:16]
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / (stem + ".md")
receipt = out_dir / (stem + ".receipt.json")
record = {"run_id": run["run_id"], "source_id": source_id, "source_sha256": digest(source), "payload_sha256": payload_hash, "report_sha256": digest(body), "reviewer": approval["reviewer"], "operation": "local action-triage register"}
encoded = (json.dumps(record, indent=2) + "\n").encode("utf-8")
for path, data in [(target, body), (receipt, encoded)]:
if path.exists():
if path.read_bytes() != data:
raise ValueError(f"uncertain prior output differs: {path}; inspect before retry")
else:
with path.open("xb") as handle:
handle.write(data)
if path.read_bytes() != data:
raise IOError(f"readback failed: {path}")
return target, receipt
def produce(source_id: str, source_path: Path, candidate_path: Path, model: str) -> None:
from agents import Agent, Runner
source = source_path.read_bytes()
source_text = source.decode("utf-8")
if not source_text.strip() or candidate_path.exists() or not candidate_path.parent.is_dir():
raise ValueError("source must contain text; candidate must be new and its parent directory must already exist")
run_id = str(uuid.uuid4())
agent = Agent(
name="Owned-source action extractor",
model=model,
instructions=("Extract only explicit action commitments from the supplied numbered source. "
"Return action-register/v1. Copy an exact quote with matching 1-based line span. "
"Use null for an unstated owner or due date; never infer them. "
"Use partial and needs_review for ambiguous evidence; blocked for unusable input. "
"Do not use tools or hand off."),
output_type=Extraction,
)
numbered = "\n".join(f"{n}: {line}" for n, line in enumerate(source_text.splitlines(), 1))
try:
result = Runner.run_sync(agent, f"Source ID: {source_id}\nSHA-256: {digest(source)}\nNumbered source:\n{numbered}")
if result.interruptions or result.last_agent is not agent or not isinstance(result.final_output, Extraction):
raise ValueError("run interrupted, unfinished, or finished by unexpected agent; this CLI does not persist resumable RunState, so do not resume from its failure receipt")
output = result.final_output
if output.source_id != source_id or output.source_sha256 != digest(source):
raise ValueError("producer returned wrong source identity")
candidate = canonical(output)
with candidate_path.open("xb") as handle:
handle.write(candidate) # Candidate only; consumer still requires review.
receipt = {"run_id": run_id, "source_id": source_id, "source_sha256": digest(source), "payload_sha256": digest(candidate), "last_agent": result.last_agent.name, "model": model}
with candidate_path.with_suffix(".run.json").open("x", encoding="utf-8") as handle:
handle.write(json.dumps(receipt, indent=2) + "\n")
except Exception as exc:
failure = {"run_id": run_id, "source_id": source_id, "source_sha256": digest(source), "error_type": type(exc).__name__, "error": str(exc), "state": "unknown_or_blocked; inspect run and destination before retry"}
with candidate_path.with_name(candidate_path.stem + "." + run_id + ".failure.json").open("x", encoding="utf-8") as handle:
handle.write(json.dumps(failure, indent=2) + "\n")
raise
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
for name in ("produce", "consume"):
p = sub.add_parser(name)
p.add_argument("--source-id", required=True)
p.add_argument("--source", type=Path, required=True)
if name == "produce":
p.add_argument("--candidate", type=Path, required=True)
p.add_argument("--model", required=True)
else:
p.add_argument("--candidate", type=Path, required=True)
p.add_argument("--approval", type=Path, required=True)
p.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
if args.command == "produce":
produce(args.source_id, args.source, args.candidate, args.model)
else:
print(*consume(args.source_id, args.source, args.candidate, args.approval, args.out), sep="\n")
if __name__ == "__main__":
main()Limites et sources
Le contrat et le consommateur local ont été contrôlés hors ligne avec cinq tests et sept cas synthétiques de producteur simulé ou de reprise. Ces contrôles couvraient la génération de schéma strict, le rejet avant création, le regroupement, les empreintes et les échecs. Ils n’ont soumis aucun schéma à un fournisseur, appelé aucun modèle, extrait aucune note réelle, obtenu aucune approbation humaine factuelle, créé aucun rapport fondé sur une vraie source ni mesuré facture ou qualité. Les frais de l’API dépendent de votre compte ; vérifiez compatibilité du modèle, tarifs et permission sur les données avant d’envoyer la source.
Pour un contexte voisin, le guide BIG CHANGE pour choisir une tâche d’IA parmi son propre travail couvre la décision antérieure sur le travail à tester. Ce guide part d’une extraction sélectionnée et autorisée, puis conduit un résultat typé vers un consommateur local vérifié.
Sources et lectures complémentaires
- OpenAI Agents SDK: Agents documente le
output_typePydantic. Il ne fournit ni l’approbation humaine ni le consommateur local de cette recette. - OpenAI Agents SDK: Results documente
final_output,last_agent, les interruptions etto_state(). Cette interface en ligne de commande ne conserve pas d’état reprenable. - OpenAI Agents SDK: Quickstart couvre l’installation et la configuration de clé API. Gardez la clé hors de la source et des reçus.
- OpenAI Structured Outputs explique les règles de schéma strict et la prise en charge des modèles. Confirmez celle du modèle choisi.
- Pydantic ConfigDict documente la validation stricte et l’interdiction des champs supplémentaires.
- OpenAI Agents SDK 0.24.0 et Pydantic 2.14.1 indiquent les versions exactes installées localement. L’acceptation côté fournisseur n’a pas été testée.



