AI-translated from English; not yet reviewed by a fluent editor.

# Transformer la sortie d’un agent en registre d’actions vérifié

> Produisez un résultat typé avec OpenAI Agents SDK à partir de vos propres notes, vérifiez ses preuves et son sens, puis transmettez le résultat accepté à un registre d’actions Markdown local avec relecture des empreintes.

By BIG CHANGE Editorial

Published: 2026-10-20T12:52:38.610Z
Updated: 2026-10-20T12:52:38.610Z
Canonical: https://bigchange.ai/blog/agents-sdk-reviewed-action-register-guide

![Conceptual charcoal illustration of one person holding a larger blank ruled source page and a smaller blank candidate page, with an orange reading strip across the source.](https://bigchange.ai/api/media/file/source-candidate-human-review-hero-v2.png)
AI-generated conceptual illustration by BIG CHANGE.

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_type` Pydantic : 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 :

```sh
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 :

```text
openai-agents==0.24.0
pydantic==2.14.1
```

```sh
python -m pip install -r recipe-requirements.txt
mkdir candidate
```

Ces 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 :

```sh
python action_register_recipe.py produce --source-id OWNED-ID --source notes.txt --candidate candidate/actions.json --model YOUR-STRUCTURED-OUTPUT-MODEL
```

Lisez `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 :

```json
{
  "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

```sh
python action_register_recipe.py consume --source-id OWNED-ID --source notes.txt --candidate candidate/actions.json --approval approval.json --out private-output
```

Le 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 :

```sh
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-RECEIPT
```

Aprè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 :

```sh
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-output
```

La 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` :

```python
"""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](https://bigchange.ai/blog/map-work-choose-first-ai-task-guide) 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](https://openai.github.io/openai-agents-python/agents/) documente le `output_type` Pydantic. Il ne fournit ni l’approbation humaine ni le consommateur local de cette recette.
- [OpenAI Agents SDK: Results](https://openai.github.io/openai-agents-python/results/) documente `final_output`, `last_agent`, les interruptions et `to_state()`. Cette interface en ligne de commande ne conserve pas d’état reprenable.
- [OpenAI Agents SDK: Quickstart](https://openai.github.io/openai-agents-python/quickstart/) couvre l’installation et la configuration de clé API. Gardez la clé hors de la source et des reçus.
- [OpenAI Structured Outputs](https://developers.openai.com/api/docs/guides/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](https://docs.pydantic.dev/latest/api/config/) documente la validation stricte et l’interdiction des champs supplémentaires.
- [OpenAI Agents SDK 0.24.0](https://pypi.org/project/openai-agents/0.24.0/) et [Pydantic 2.14.1](https://pypi.org/project/pydantic/2.14.1/) indiquent les versions exactes installées localement. L’acceptation côté fournisseur n’a pas été testée.

## Sources

- [OpenAI Agents SDK: Agents](https://openai.github.io/openai-agents-python/agents/) — Manuel officiel de output_type Pydantic. Il n’implémente pas l’approbation humaine ou le consommateur local de l’article.
- [OpenAI Agents SDK: Results](https://openai.github.io/openai-agents-python/results/) — Comportement officiel de final_output, last_agent, des interruptions et de to_state. La recette ne conserve pas RunState et ne propose pas de reprise.
- [OpenAI Agents SDK: Quickstart](https://openai.github.io/openai-agents-python/quickstart/) — Contexte officiel d’installation et de configuration de clé API ; aucune clé ni requête réelle au fournisseur n’a été utilisée lors de la préparation.
- [OpenAI Agents SDK : référence du schéma de sortie](https://openai.github.io/openai-agents-python/ref/agent_output/) — Référence officielle de génération de schéma strict ; l’inspection locale ne prouve pas l’acceptation par le fournisseur.
- [OpenAI Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) — Règles strictes sur les champs obligatoires et additionalProperties, plus compatibilité des modèles ; le lecteur doit vérifier son modèle au moment de l’utilisation.
- [Pydantic ConfigDict](https://docs.pydantic.dev/latest/api/config/) — Décrit les paramètres strict et extra-forbid ; l’exactitude du sens exige toujours une revue humaine de la source.
- [Version openai-agents 0.24.0](https://pypi.org/project/openai-agents/0.24.0/) — Version exacte du SDK installée dans le contrôle local isolé Python 3.12.7 ; aucun appel au modèle ni facture.
- [Version Pydantic 2.14.1](https://pypi.org/project/pydantic/2.14.1/) — Version exacte de la bibliothèque de validation installée localement avec le SDK 0.24.0.
- [BIG CHANGE : Cartographier son travail et choisir une première tâche à tester avec l’IA](https://bigchange.ai/blog/map-work-choose-first-ai-task-guide) — Guide contextuel pour choisir une tâche avant cette recette d’intégration ; il ne démontre pas sa validité.
