Ce construim și ce trebuie să înțelegi
În acest laborator transformăm noțiunile din Lecția 2 într-un program pe care îl poți urmări pas cu pas. Construim un agent determinist pentru verificarea unei facturi fictive: citește datele facturii, verifică dacă informațiile necesare există și au forma așteptată, calculează totalurile, pregătește un pachet pentru verificarea umană și decide dacă trebuie să continue, să aștepte, să se blocheze sau să se oprească.
Scopul principal nu este contabilitatea. Factura este doar un exemplu familiar, cu date ușor de verificat manual. Scopul real este să vezi cum arată în cod bucla de control a unui agent: observație → alegerea unei acțiuni → executarea acțiunii → actualizarea stării → o nouă observație.
Ce trebuie să poți explica la final
- de ce agentul păstrează starea separat de documentul pe care îl verifică;
- de ce datele lipsă trebuie tratate înainte ca agentul să încerce să avanseze spre obiectiv;
- de ce un calcul exact este delegat codului determinist, nu unui model lingvistic;
- de ce o cerere de trimitere și o aprobare sunt date de control, nu câmpuri ale facturii;
- cum poți reconstrui fiecare decizie din jurnalul de execuție;
- cum demonstrează testele că regulile importante continuă să funcționeze după modificări.
FACTURĂ (date externe)
↓
OBSERVĂ: ce lipsește? ce este invalid? ce s-a calculat deja?
↓
DECIDE: care este următoarea acțiune permisă?
↓
EXECUTĂ: validează / calculează / pregătește / așteaptă
↓
ACTUALIZEAZĂ STAREA
↓
OBSERVĂ DIN NOU
↺
OPRIRE: verificare umană / aprobare / blocare / finalizare / eșec
Programul nu folosește încă un LLM. Alegerea este intenționată: dacă bucla este deterministă, putem vedea exact de ce a fost aleasă fiecare acțiune și putem demonstra prin teste că o regulă de siguranță nu este ocolită. Mai târziu, un model va putea prelua acele decizii pentru care regulile fixe nu sunt suficiente — de exemplu, când trebuie interpretat limbaj natural sau o situație ambiguă.
1. Recapitulează instrumentele folosite
Dacă ai terminat Laboratorul 1, majoritatea instrumentelor sunt deja instalate. Nu le învățăm din nou de la zero; verificăm rolul fiecăruia și adăugăm Graphviz, pe care îl folosim pentru diagrama agentului.
Python 3
Limbajul în care scriem agentul și testele. În această lecție folosim numai biblioteca standard Python: nu instalăm biblioteci Python externe.
Git
Păstrează istoricul proiectului. Laboratorul 2 continuă același repository Git creat în Laboratorul 1.
jq
Citește și filtrează JSON în terminal. Îl folosim atât pentru a verifica fișierele de intrare, cât și pentru a afișa numai câmpurile importante din rezultatul agentului.
tree
Afișează structura folderelor. Este un instrument de orientare: te ajută să verifici rapid dacă fișierele au ajuns în locurile corecte.
Graphviz
Transformă o descriere text în format DOT într-o diagramă PNG. Diagrama nu controlează agentul; documentează vizual aceeași buclă pe care o implementează codul.
Dacă ai făcut Laboratorul 1
Nu este nevoie să recreezi proiectul de la zero. Folosește același folder ~/ai-agents-zero-to-expert, același mediu .venv și același repository Git. Laboratorul 2 adaugă fișiere noi peste structura deja creată.
2. Verifică instalarea
Mai întâi ne asigurăm că instrumentele există. Dacă ai rulat deja Laboratorul 1, comanda de instalare este sigură: apt va păstra pachetele care sunt deja instalate și va adăuga doar ce lipsește, în special Graphviz.
Ce fac comenzile
sudo apt updateactualizează catalogul local de pachete.sudo apt install -y ...instalează instrumentele enumerate. Opțiunea-yconfirmă automat instalarea.
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git jq tree graphviz
Apoi cerem fiecărui program să își afișeze versiunea:
python3 --version
git --version
jq --version
tree --version
dot -V
dot -V, verificarea a reușit.3. Continuă proiectul cumulativ
Toate laboratoarele folosesc același folder principal. Astfel, ceea ce construiești într-o lecție devine baza lecției următoare, în loc să creezi proiecte izolate care nu se mai întâlnesc.
mkdir -p ~/ai-agents-zero-to-expert
cd ~/ai-agents-zero-to-expert
pwd
mkdir -p creează folderul dacă lipsește și nu produce eroare dacă există deja. cd intră în folder, iar pwd îți arată unde te afli. Rezultatul trebuie să se termine cu /ai-agents-zero-to-expert.
Creează subfolderele de care are nevoie Laboratorul 2. Păstrăm și folderele introduse în Laboratorul 1, pentru ca proiectul să rămână coerent chiar dacă cineva începe direct de aici:
mkdir -p \
src \
tests \
configs \
data/inbox \
data/archive \
diagrams \
logs/lesson02 \
docs/lessons/lesson-02 \
docs/architecture-decisions \
skills \
evaluations
src/codul agentuluitests/teste automateconfigs/setări separate de coddata/inbox/facturile demonstrativediagrams/sursa DOT și imaginea PNGlogs/lesson02/jurnale generate la rularedocs/proiectare și decizii arhitecturaleBara inversă \ de la sfârșitul unei linii îi spune shell-ului că aceeași comandă continuă pe linia următoare. Nu sunt mai multe comenzi mkdir; este una singură, scrisă pe mai multe linii pentru lizibilitate.
4. Activează mediul Python
Mediul virtual .venv izolează Python-ul proiectului. Dacă l-ai creat în Laboratorul 1, nu îl recrea: doar activează-l. Dacă începi direct cu Laboratorul 2, creează-l o singură dată.
Alege situația ta
- Ai făcut Laboratorul 1: rulează numai
source .venv/bin/activate. - Începi direct aici: rulează mai întâi
python3 -m venv .venv, apoi activează mediul.
python3 -m venv .venv # rulează această linie numai dacă .venv nu există
source .venv/bin/activate
python -m pip install --upgrade pip
După activare, promptul terminalului ar trebui să înceapă cu (.venv). Confirmă că Python vine din proiect:
which python
python --version
Calea afișată de which python trebuie să conțină .venv/bin/python. Această verificare contează: dacă folosești accidental Python-ul global al sistemului, mai târziu poți instala dependențe în locul greșit.
5. Verifică Git și protecțiile proiectului
Laboratorul 2 continuă același repository Git. Dacă rulezi git init într-un proiect deja inițializat, Git poate spune că a reinițializat repository-ul; nu pierzi istoricul.
git config --global init.defaultBranch main
git init
Fișierul .gitignore trebuie să păstreze protecțiile din Laboratorul 1 și să ignore jurnalele noi. Creează sau înlocuiește conținutul cu:
cat > .gitignore <<'EOF'
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.env
.env.*
logs/*
!logs/.gitkeep
*.tmp
.DS_Store
EOF
touch logs/.gitkeep
.gitignore nu șterge fișiere și nu le criptează. El doar îi spune lui Git ce să nu includă accidental într-un commit. Ignorăm .env deoarece poate conține secrete și ignorăm jurnalele deoarece pot conține date de rulare. Fișierul gol logs/.gitkeep permite păstrarea structurii folderului în Git.
6. Creează configurația agentului
Separăm configurația de cod pentru ca o regulă de operare să poată fi schimbată fără să rescriem funcțiile Python. În acest fișier definim obiectivul, câmpurile obligatorii, cota folosită în exemplu, limita de pași și regula de aprobare.
Creează fișierul direct din terminal:
cat > configs/invoice_agent.json <<'JSON'
{
"agent_name": "lesson02-invoice-review-agent",
"goal": "prepare_invoice_for_human_review",
"required_fields": ["seller", "customer", "invoice_number", "currency", "items"],
"tax_rate": "0.21",
"max_steps": 12,
"sending_requires_approval": true
}
JSON
Cum citești această configurație
agent_nameeste doar un identificator lizibil.goalexprimă rezultatul urmărit: pregătirea facturii pentru verificarea unui om.required_fieldsenumeră informațiile fără de care agentul nu poate continua.tax_rateeste cota folosită strict pentru aritmetica exemplului.max_stepspune o limită explicită buclei, ca programul să nu poată continua la nesfârșit.sending_requires_approvalspune că simularea trimiterii este interzisă până când există aprobare.
Verifică faptul că fișierul este JSON valid și apoi citește numai obiectivul:
jq . configs/invoice_agent.json
jq -r '.goal' configs/invoice_agent.json
Prima comandă trebuie să afișeze obiectul formatat. A doua trebuie să afișeze prepare_invoice_for_human_review. Dacă prima comandă eșuează, nu continua până nu corectezi JSON-ul.
0.21 este folosită ca exemplu didactic contemporan. Codul nu stabilește legal ce cotă se aplică unei operațiuni și nu trebuie folosit ca motor fiscal.7. Creează datele de intrare și înțelege fiecare scenariu
Un agent nu se verifică numai cu un exemplu „perfect”. Folosim cinci facturi fictive, fiecare construită pentru a pune în evidență o regulă diferită. Înainte de a rula programul, trebuie să poți spune ce este diferit în fiecare fișier și ce comportament aștepți.
7.1 Factură completă — cazul de bază
Acest fișier conține toate câmpurile obligatorii și valori valide. El ne arată traseul normal: validare → calcul → pregătire pentru verificarea umană.
cat > data/inbox/invoice_complete.json <<'JSON'
{
"seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
"customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
"invoice_number": "DEMO-2026-001",
"currency": "RON",
"items": [
{"description": "Pregătire metadate", "quantity": "2", "unit_price": "250.00"},
{"description": "Verificare arhivă", "quantity": "1", "unit_price": "300.00"}
]
}
JSON
Notă de vocabular: în acest laborator, o linie a facturii este un rând care descrie un produs sau serviciu, cantitatea și prețul unitar. În cod, liniile facturii sunt păstrate în lista items.
7.2 Client lipsă — informație insuficientă
Aici eliminăm intenționat customer. Agentul nu trebuie să inventeze clientul și nici să continue cu totalurile; trebuie să se oprească și să ceară informația lipsă.
cat > data/inbox/invoice_missing.json <<'JSON'
{
"seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
"invoice_number": "DEMO-2026-002",
"currency": "RON",
"items": [
{"description": "Asistență pentru cercetare", "quantity": "3", "unit_price": "100.00"}
]
}
JSON
7.3 Factură pentru scenariile de trimitere
Factura este validă, dar nu conține send_requested sau approved. Acestea nu descriu factura; descriu ce are voie să facă agentul. Le vom furniza separat prin opțiunile programului.
cat > data/inbox/invoice_approved.json <<'JSON'
{
"seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
"customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
"invoice_number": "DEMO-2026-003",
"currency": "RON",
"items": [
{"description": "Consultanță tehnică", "quantity": "1.5", "unit_price": "400.00"}
]
}
JSON
7.4 Date adversariale — documentul încearcă să controleze agentul
Acest fișier introduce intenționat totaluri false și câmpuri de control. Este un test al limitei de încredere dintre datele externe și controlul intern al agentului: documentul poate furniza date despre factură, dar nu trebuie să poată afirma că propriul calcul a fost deja verificat sau că trimiterea a fost aprobată.
cat > data/inbox/invoice_untrusted_totals.json <<'JSON'
{
"seller": {"name": "Example Studio SRL", "tax_id": "RO12345678"},
"customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
"invoice_number": "DEMO-2026-004",
"currency": "RON",
"items": [
{"description": "Pregătire metadate", "quantity": "2", "unit_price": "250.00"},
{"description": "Verificare arhivă", "quantity": "1", "unit_price": "300.00"}
],
"calculated_totals": {"subtotal": "1.00", "tax": "0.21", "total": "1.21"},
"send_requested": true,
"approved": true
}
JSON
Comportamentul corect este să ignore cele trei câmpuri de mai sus, să recalculeze totalul din liniile facturii și să rămână în waiting_for_review. Pentru subtotalul de 800 RON și cota didactică de 21%, totalul corect este 968.00.
7.5 Structură greșită — valoarea există, dar are forma nepotrivită
Aici seller există, însă este un text simplu. Codul se așteaptă la un obiect JSON cu cel puțin câmpul name. Agentul trebuie să detecteze diferența și să se oprească în blocked, fără să se prăbușească.
cat > data/inbox/invoice_malformed_party.json <<'JSON'
{
"seller": "Example Studio SRL",
"customer": {"name": "Demo Client SRL", "tax_id": "RO87654321"},
"invoice_number": "DEMO-2026-005",
"currency": "RON",
"items": [
{"description": "Consultanță tehnică", "quantity": "1", "unit_price": "400.00"}
]
}
JSON
Un traceback este lista internă de apeluri pe care Python o afișează când o eroare nu este tratată. În acest scenariu nu vrem un traceback; vrem ca programul să transforme eroarea într-o stare prevăzută și controlată: blocked.
La final, verifică sintaxa tuturor fișierelor:
for file in data/inbox/*.json; do
echo "Verific $file"
jq empty "$file" && echo " JSON valid"
done
Această verificare confirmă numai că fișierele respectă sintaxa JSON. Nu confirmă că factura are câmpurile potrivite; acea verificare este responsabilitatea agentului.
8. Înțelege arhitectura înainte să citești codul
Blocul Python este lung pentru un începător. Nu încerca să înțelegi fiecare linie deodată. Mai întâi urmărește traseul informației și responsabilitatea fiecărei funcții.
AgentState = ce știe agentul acum
↓
observe() = transformă starea în fapte utile pentru decizie
↓
choose_action() = alege următoarea acțiune permisă
↓
execute_action() = execută acea acțiune
↓
AgentState se modifică
↓
bucla reîncepe până când status != running
Harta programului
Statuseste o enumerare care definește stările permise ale agentului: cele intermediare (running) și cele finale (waiting_for_input,waiting_for_review,waiting_for_approval,blocked,completed,failed). Este separată pentru că toate celelalte componente scriu și citesc doar stări din această listă — o stare nouă nu poate apărea decât prin această listă.AgentStateeste clasa care ține minte obiectivul, factura, rezultatele calculate, aprobarea, pașii făcuți și motivul opririi; se creează din configurație și factură înbuild_state()și este singura structură pe care funcțiile o transmit unele altora. Este separată pentru că starea este contractul între pași: fiecare pas citește din ea și scrie în ea.- Funcțiile de validare —
inspect_missing_fields(),validate_metadata(),validate_parties(),validate_items()— primesc starea și întorc liste de erori sau dicționare cuvalidșierrors. Sunt separate pentru că verifică datele înainte de orice folosire, iar fiecare verificare poate fi testată izolat. calculate_totals()primește starea cu factura validată, recalculează subtotalul, taxa și totalul cuDecimalși întoarce un dicționar cuokși totalurile formatate. Este separată pentru că aritmetica trebuie să fie exactă și verificabilă, nu probabilistică.prepare_review_package()primește starea cu totaluri deja calculate și construiește, ca dicționar, pachetul care va fi verificat de un om; cere explicit totaluri existente și eșuează controlat în lipsa lor. Este separată pentru că „ce vede omul” este un produs diferit de „ce a calculat agentul”.observe()primește starea și întoarce un dicționar cu situația curentă: câmpurile lipsă, erorile de validare, prezența totalurilor și a pachetului, cererile de trimitere și numărul de pași. Este separată pentru că decizia trebuie să se bazeze pe o descriere completă a situației, nu pe impresii răspândite prin cod.choose_action()primește starea și observația și întoarce un singur text — numele acțiunii permise în situația respectivă. Este separată pentru că politica de alegere este exact locul unde regulile de siguranță și ordinea lor se pot citi și testa.execute_action()primește starea, numele acțiunii și observația, execută acțiunea respectivă — validează, calculează, pregătește, cere date sau blochează — și întoarce un dicționar cu rezultatul pasului. Este separată pentru că efectul real se produce numai aici, sub controlul politicii.run_agent()primește starea inițială și întoarce starea finală; este bucla care repetă observarea, decizia și acțiunea până când statusul iese dinrunningsau se atinge limita de pași. Este separată pentru că bucla, nu modelul, este mecanismul care face sistemul agent.write_trace()primește starea și o cale de fișier și scrie, linie cu linie în format JSONL, jurnalul cronologic al pașilor; nu întoarce nimic. Este separată pentru că dovada execuției trebuie să fie scrisă de cod, nu reconstituită din memorie.
8.1 Date observate versus rezultate produse de agent
Factura este documentul-sursă pe care agentul îl examinează: de aici citește părțile și liniile facturii. Totalul recalculat este însă un rezultat derivat, adică o valoare produsă de program din datele sursă. Dacă am păstra datele primite și rezultatele calculate în același loc și am avea încredere în orice câmp primit, documentul ar putea pretinde că verificarea a avut deja loc.
De aceea build_state() copiază factura și elimină câmpurile de control sau rezultatele derivate pe care sursa încearcă să le introducă: calculated_totals, simulated_send, simulated_send_record, send_requested și approved. Valorile corecte sunt păstrate separat în AgentState.
8.2 Cererea de trimitere și aprobarea sunt intrări de control
În laborator, --request-send arată că cineva solicită trimiterea, iar --approve-send simulează aprobarea unei persoane autorizate. Acestea sunt semnale de control furnizate separat de datele facturii. Sunt încă simplificări didactice, dar vin din afara documentului verificat. Într-un sistem real, aprobarea ar proveni dintr-o identitate autentificată și ar fi înregistrată împreună cu cine a aprobat, ce anume și când.
9. Creează programul și verifică-i sintaxa
Acum transpunem arhitectura în Python. Salvează codul în src/lesson02_invoice_agent.py. Programul nu contactează rețeaua, nu modifică factura-sursă și nu efectuează o trimitere reală.
Cum să citești codul fără să te pierzi
- Citește mai întâi numai
StatusșiAgentState: acestea îți spun ce poate ține minte agentul. - Citește funcțiile de validare și
calculate_totals(): acestea sunt instrumentele deterministe. - Citește
observe(),choose_action()șiexecute_action()împreună. - Abia la final citește
run_agent(); vei vedea că bucla este scurtă tocmai pentru că responsabilitățile au fost separate.
Termeni Python pe care îi vei vedea în cod
@dataclass creează o clasă potrivită pentru păstrarea datelor. Enum limitează o valoare la un set cunoscut de opțiuni. Decimal este folosit pentru bani, deoarece evită unele erori de reprezentare ale numerelor zecimale în float. Adnotările precum list[str] descriu tipul așteptat și fac programul mai ușor de citit; Python nu le transformă automat în validări.
#!/usr/bin/env python3
"""Agent determinist și inspectabil pentru laboratorul Lecției 2.
Factura este tratată ca sursă externă de date și nu poate furniza starea de
aprobare a agentului. Totalurile calculate, rezultatele validării și acțiunile
simulate sunt păstrate separat în AgentState. Programul nu contactează servicii
externe.
"""
from __future__ import annotations
import argparse
import copy
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from enum import Enum
from pathlib import Path
from typing import Any
MONEY_QUANTUM = Decimal("0.01")
class Status(str, Enum):
RUNNING = "running"
WAITING_FOR_INPUT = "waiting_for_input"
WAITING_FOR_REVIEW = "waiting_for_review"
WAITING_FOR_APPROVAL = "waiting_for_approval"
BLOCKED = "blocked"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class TraceEntry:
step: int
observation: dict[str, Any]
selected_action: str
result: dict[str, Any]
status_after_action: str
@dataclass
class AgentState:
goal: str
invoice: dict[str, Any]
required_fields: list[str]
tax_rate: Decimal
max_steps: int
sending_requires_approval: bool
send_requested: bool = False
approved: bool = False
status: Status = Status.RUNNING
step_count: int = 0
actions_taken: list[str] = field(default_factory=list)
missing_fields: list[str] = field(default_factory=list)
validation_errors: list[str] = field(default_factory=list)
calculated_totals: dict[str, str] | None = None
review_package: dict[str, Any] | None = None
simulated_send_record: dict[str, Any] | None = None
ignored_input_fields: list[str] = field(default_factory=list)
stop_reason: str | None = None
trace: list[TraceEntry] = field(default_factory=list)
def load_json(path: Path) -> dict[str, Any]:
"""Citește un obiect JSON și respinge fișierele lipsă, invalide sau de alt tip."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ValueError(f"Fișierul nu există: {path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"JSON invalid în {path}: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(f"Fișierul {path} trebuie să conțină un obiect JSON")
return data
def quantize_money(value: Decimal) -> Decimal:
return value.quantize(MONEY_QUANTUM, rounding=ROUND_HALF_UP)
def money(value: Decimal) -> str:
return format(quantize_money(value), ".2f")
def inspect_missing_fields(state: AgentState) -> list[str]:
missing: list[str] = []
for field_name in state.required_fields:
value = state.invoice.get(field_name)
if value is None or value == "" or value == [] or value == {}:
missing.append(field_name)
return missing
def validate_metadata(state: AgentState) -> dict[str, Any]:
errors: list[str] = []
invoice_number = state.invoice.get("invoice_number")
currency = state.invoice.get("currency")
if invoice_number is not None and (
not isinstance(invoice_number, str) or not invoice_number.strip()
):
errors.append("invoice_number trebuie să fie un text nevid")
if currency is not None and (
not isinstance(currency, str) or not currency.strip()
):
errors.append("currency trebuie să fie un text nevid")
return {"valid": not errors, "errors": errors}
def validate_parties(state: AgentState) -> dict[str, Any]:
"""Validează vânzătorul și clientul înainte de accesarea câmpurilor lor."""
errors: list[str] = []
for party_name in ("seller", "customer"):
party = state.invoice.get(party_name)
if party is None:
continue # Câmpurile de nivel superior lipsă sunt tratate separat.
if not isinstance(party, dict):
errors.append(f"{party_name} trebuie să fie un obiect JSON")
continue
name = party.get("name")
if not isinstance(name, str) or not name.strip():
errors.append(f"{party_name}.name este obligatoriu și trebuie să fie text")
return {"valid": not errors, "errors": errors}
def _decimal_field(value: Any, label: str) -> tuple[Decimal | None, str | None]:
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None, f"{label} nu este un număr valid"
if not number.is_finite():
return None, f"{label} trebuie să fie un număr finit"
return number, None
def validate_items(state: AgentState) -> dict[str, Any]:
items = state.invoice.get("items")
if not isinstance(items, list) or not items:
return {"valid": False, "errors": ["items trebuie să fie o listă nevidă"]}
errors: list[str] = []
for index, item in enumerate(items, start=1):
if not isinstance(item, dict):
errors.append(f"poziția {index} trebuie să fie un obiect JSON")
continue
description = item.get("description")
if not isinstance(description, str) or not description.strip():
errors.append(f"poziția {index}: description este obligatoriu și trebuie să fie text")
quantity, quantity_error = _decimal_field(
item.get("quantity"), f"poziția {index}: quantity"
)
unit_price, price_error = _decimal_field(
item.get("unit_price"), f"poziția {index}: unit_price"
)
if quantity_error:
errors.append(quantity_error)
if price_error:
errors.append(price_error)
if quantity is not None and quantity <= 0:
errors.append(f"poziția {index}: quantity trebuie să fie mai mare decât zero")
if unit_price is not None and unit_price < 0:
errors.append(f"poziția {index}: unit_price nu poate fi negativ")
return {"valid": not errors, "errors": errors}
def calculate_totals(state: AgentState) -> dict[str, Any]:
subtotal = Decimal("0.00")
for item in state.invoice["items"]:
quantity = Decimal(str(item["quantity"]))
unit_price = Decimal(str(item["unit_price"]))
line_total = quantize_money(quantity * unit_price)
subtotal += line_total
subtotal = quantize_money(subtotal)
tax = quantize_money(subtotal * state.tax_rate)
total = subtotal + tax
state.calculated_totals = {
"subtotal": money(subtotal),
"tax_rate": str(state.tax_rate),
"tax": money(tax),
"total": money(total),
}
return {"ok": True, "totals": state.calculated_totals}
def prepare_review_package(state: AgentState) -> dict[str, Any]:
if state.calculated_totals is None:
raise RuntimeError("pachetul pentru verificare necesită totaluri calculate de agent")
seller = state.invoice["seller"]
customer = state.invoice["customer"]
package = {
"invoice_number": state.invoice["invoice_number"],
"seller_name": seller.get("name"),
"customer_name": customer.get("name"),
"currency": state.invoice["currency"],
"calculated_totals": state.calculated_totals,
"human_review_required": True,
"send_requested": state.send_requested,
"approval_required_for_send": (
state.send_requested and state.sending_requires_approval
),
"external_send_performed": False,
}
state.review_package = package
return {"ok": True, "review_package": package}
def simulate_send(state: AgentState) -> dict[str, Any]:
# Apărare în profunzime: executorul verifică din nou condițiile importante,
# chiar dacă politica de acțiune ar trebui să fi făcut deja verificarea.
if not state.send_requested:
raise RuntimeError("trimiterea simulată nu a fost solicitată")
if state.sending_requires_approval and not state.approved:
raise RuntimeError("trimiterea simulată necesită aprobare explicită")
state.simulated_send_record = {
"performed": True,
"timestamp": datetime.now(timezone.utc).isoformat(),
"note": "Nu a fost contactat niciun serviciu extern.",
}
state.status = Status.COMPLETED
state.stop_reason = "trimiterea simulată aprobată a fost finalizată"
return {"ok": True, "external_side_effect": False}
def observe(state: AgentState) -> dict[str, Any]:
missing = inspect_missing_fields(state)
state.missing_fields = missing
metadata_check = validate_metadata(state)
party_check = validate_parties(state)
item_check = (
validate_items(state)
if "items" not in missing
else {"valid": False, "errors": ["items nu este disponibil"]}
)
state.validation_errors = (
metadata_check["errors"] + party_check["errors"] + item_check["errors"]
)
return {
"missing_fields": missing,
"metadata_valid": metadata_check["valid"],
"metadata_errors": metadata_check["errors"],
"parties_valid": party_check["valid"],
"party_errors": party_check["errors"],
"items_valid": item_check["valid"],
"item_errors": item_check["errors"],
"totals_present": state.calculated_totals is not None,
"review_present": state.review_package is not None,
"send_requested": state.send_requested,
"approved": state.approved,
"ignored_input_fields": list(state.ignored_input_fields),
"step_count": state.step_count,
"max_steps": state.max_steps,
}
def choose_action(state: AgentState, observation: dict[str, Any]) -> str:
if observation["missing_fields"]:
return "request_missing_information"
if not observation["metadata_valid"]:
return "block_invalid_metadata"
if not observation["parties_valid"]:
return "block_invalid_structure"
if not observation["items_valid"]:
return "block_invalid_items"
if not observation["totals_present"]:
return "calculate_totals"
if not observation["review_present"]:
return "prepare_review_package"
if not observation["send_requested"]:
return "wait_for_human_review"
if state.sending_requires_approval and not observation["approved"]:
return "wait_for_approval"
return "simulate_send"
def execute_action(
state: AgentState,
action: str,
observation: dict[str, Any],
) -> dict[str, Any]:
if action == "request_missing_information":
state.status = Status.WAITING_FOR_INPUT
state.stop_reason = "lipsesc câmpuri obligatorii din factură"
return {"ok": False, "missing_fields": observation["missing_fields"]}
if action == "block_invalid_metadata":
state.status = Status.BLOCKED
state.stop_reason = "metadatele facturii au o structură invalidă"
return {"ok": False, "errors": observation["metadata_errors"]}
if action == "block_invalid_structure":
state.status = Status.BLOCKED
state.stop_reason = "structura vânzătorului sau a clientului este invalidă"
return {"ok": False, "errors": observation["party_errors"]}
if action == "block_invalid_items":
state.status = Status.BLOCKED
state.stop_reason = "pozițiile facturii sunt invalide"
return {"ok": False, "errors": observation["item_errors"]}
if action == "calculate_totals":
return calculate_totals(state)
if action == "prepare_review_package":
return prepare_review_package(state)
if action == "wait_for_human_review":
state.status = Status.WAITING_FOR_REVIEW
state.stop_reason = "pachetul este pregătit și așteaptă verificarea unui om"
return {"ok": True, "waiting_for": "human_review"}
if action == "wait_for_approval":
state.status = Status.WAITING_FOR_APPROVAL
state.stop_reason = "este necesară aprobarea explicită înainte de trimiterea simulată"
return {"ok": True, "waiting_for": "approval"}
if action == "simulate_send":
return simulate_send(state)
state.status = Status.FAILED
state.stop_reason = f"acțiune necunoscută: {action}"
return {"ok": False, "error": state.stop_reason}
def run_agent(state: AgentState) -> AgentState:
while state.status == Status.RUNNING:
if state.step_count >= state.max_steps:
state.status = Status.FAILED
state.stop_reason = "a fost atins numărul maxim de pași"
break
observation = observe(state)
action = choose_action(state, observation)
state.step_count += 1
state.actions_taken.append(action)
result = execute_action(state, action, observation)
state.trace.append(
TraceEntry(
step=state.step_count,
observation=observation,
selected_action=action,
result=result,
status_after_action=state.status.value,
)
)
return state
def serializable_state(state: AgentState) -> dict[str, Any]:
data = asdict(state)
data["tax_rate"] = str(state.tax_rate)
data["status"] = state.status.value
return data
def write_trace(state: AgentState, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for entry in state.trace:
handle.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
def build_state(
config: dict[str, Any],
invoice: dict[str, Any],
*,
send_requested: bool = False,
approved: bool = False,
) -> AgentState:
goal_raw = config.get("goal")
required_fields_raw = config.get("required_fields")
tax_rate_raw = config.get("tax_rate")
max_steps_raw = config.get("max_steps")
approval_raw = config.get("sending_requires_approval")
if not isinstance(goal_raw, str) or not goal_raw.strip():
raise ValueError("goal trebuie să fie un text nevid")
if not isinstance(required_fields_raw, list) or not required_fields_raw or not all(
isinstance(name, str) and name.strip() for name in required_fields_raw
):
raise ValueError("required_fields trebuie să fie o listă nevidă de texte")
if not isinstance(max_steps_raw, int) or isinstance(max_steps_raw, bool):
raise ValueError("max_steps trebuie să fie un număr întreg")
if max_steps_raw <= 0:
raise ValueError("max_steps trebuie să fie mai mare decât zero")
if not isinstance(approval_raw, bool):
raise ValueError("sending_requires_approval trebuie să fie true sau false în JSON")
if not isinstance(send_requested, bool) or not isinstance(approved, bool):
raise ValueError("stările request/approval trebuie să fie booleene")
try:
tax_rate = Decimal(str(tax_rate_raw))
except (InvalidOperation, TypeError, ValueError) as exc:
raise ValueError("tax_rate nu este un număr valid") from exc
if not tax_rate.is_finite() or tax_rate < 0 or tax_rate > 1:
raise ValueError("tax_rate trebuie să fie un număr finit între 0 și 1")
source_invoice = copy.deepcopy(invoice)
ignored_fields: list[str] = []
for untrusted_control_or_derived_field in (
"calculated_totals",
"simulated_send",
"simulated_send_record",
"send_requested",
"approved",
):
if untrusted_control_or_derived_field in source_invoice:
source_invoice.pop(untrusted_control_or_derived_field)
ignored_fields.append(untrusted_control_or_derived_field)
return AgentState(
goal=goal_raw,
invoice=source_invoice,
required_fields=list(required_fields_raw),
tax_rate=tax_rate,
max_steps=max_steps_raw,
sending_requires_approval=approval_raw,
send_requested=send_requested,
approved=approved,
ignored_input_fields=ignored_fields,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Rulează simulatorul agentului de verificare a facturilor din Lecția 2."
)
parser.add_argument("--input", required=True, type=Path, help="Fișierul JSON al facturii")
parser.add_argument(
"--config",
type=Path,
default=Path("configs/invoice_agent.json"),
help="Fișierul JSON de configurare a agentului",
)
parser.add_argument(
"--trace-file",
type=Path,
default=Path("logs/lesson02/latest-trace.jsonl"),
help="Fișierul JSONL în care se scrie urma de execuție",
)
parser.add_argument(
"--request-send",
action="store_true",
help="Solicită explicit pasul de trimitere simulată după verificare",
)
parser.add_argument(
"--approve-send",
action="store_true",
help="Aprobă explicit trimiterea simulată; se folosește împreună cu --request-send",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.approve_send and not args.request_send:
print(
json.dumps(
{
"status": "failed",
"error": "--approve-send are sens numai împreună cu --request-send",
},
indent=2,
ensure_ascii=False,
)
)
return 2
try:
config = load_json(args.config)
invoice = load_json(args.input)
final_state = run_agent(
build_state(
config,
invoice,
send_requested=args.request_send,
approved=args.approve_send,
)
)
write_trace(final_state, args.trace_file)
except (ValueError, RuntimeError) as exc:
print(
json.dumps(
{"status": "failed", "error": str(exc)},
indent=2,
ensure_ascii=False,
)
)
return 2
print(json.dumps(serializable_state(final_state), indent=2, ensure_ascii=False))
return 0 if final_state.status != Status.FAILED else 1
if __name__ == "__main__":
raise SystemExit(main())
Înainte să rulezi agentul, verifică dacă fișierul este sintactic valid:
python -m py_compile src/lesson02_invoice_agent.py
Dacă această comandă nu afișează nimic, compilarea sintactică a reușit. Apoi fă scriptul executabil și cere pagina de ajutor:
chmod +x src/lesson02_invoice_agent.py
./src/lesson02_invoice_agent.py --help
chmod +x acordă permisiunea de executare. Prima linie a fișierului, #!/usr/bin/env python3, este shebang-ul și îi spune Linux-ului ce interpretor să folosească atunci când rulezi fișierul direct.
--help: nu dovedește că logica agentului este corectă, dar confirmă că fișierul poate fi importat și pornit, iar argumentele liniei de comandă au fost construite fără eroare.10. Rulează scenariile și interpretează traseul agentului
Nu urmărim numai starea finală. Pentru fiecare scenariu verificăm ce acțiuni a ales agentul și de ce. Aceasta este diferența dintre „programul a dat un rezultat” și „înțeleg traseul prin care a ajuns la rezultat”.
Cum citești comenzile de mai jos
--inputalege factura.--request-sendadaugă o cerere externă de trimitere.--approve-sendadaugă aprobarea externă simulată.--trace-filealege unde se salvează jurnalul execuției.- Simbolul
|trimite JSON-ul produs de agent cătrejq, care afișează numai câmpurile pe care vrem să le inspectăm.
10.1 Factura completă: agentul pregătește, dar nu trimite
./src/lesson02_invoice_agent.py \
--input data/inbox/invoice_complete.json \
--trace-file logs/lesson02/complete.jsonl \
| jq '{status, actions_taken, calculated_totals, stop_reason}'
Ce trebuie să vezi: status este waiting_for_review, iar acțiunile sunt calculate_totals → prepare_review_package → wait_for_human_review. Totalul este 968.00.
Ce demonstrează: faptul că datele sunt valide nu înseamnă că agentul primește automat autoritatea de a trimite ceva. Partea automată se încheie în punctul în care este necesară verificarea umană.
10.2 Client lipsă: lipsa datelor are prioritate
./src/lesson02_invoice_agent.py \
--input data/inbox/invoice_missing.json \
--trace-file logs/lesson02/missing.jsonl \
| jq '{status, missing_fields, actions_taken, stop_reason}'
Ce trebuie să vezi: waiting_for_input, iar customer apare în missing_fields. calculate_totals nu trebuie să apară.
Ce demonstrează: agentul verifică dacă problema este suficient de bine definită înainte să consume pași pentru progres. Nu inventează informația lipsă.
10.3 Document adversarial: rezultatele și aprobarea injectate sunt ignorate
./src/lesson02_invoice_agent.py \
--input data/inbox/invoice_untrusted_totals.json \
--trace-file logs/lesson02/untrusted.jsonl \
| jq '{status, actions_taken, calculated_totals, ignored_input_fields}'
Ce trebuie să vezi: agentul recalculează totalul la 968.00, iar calculated_totals, send_requested și approved apar în ignored_input_fields.
Ce demonstrează: programul separă datele pe care le observă de rezultatele și permisiunile pe care numai agentul sau un canal extern de control le pot produce.
10.4 Structură malformată: eroarea devine stare controlată
./src/lesson02_invoice_agent.py \
--input data/inbox/invoice_malformed_party.json \
--trace-file logs/lesson02/malformed.jsonl \
| jq '{status, actions_taken, validation_errors, stop_reason}'
Ce trebuie să vezi: blocked și o explicație despre structura lui seller, fără traceback.
Ce demonstrează: o valoare greșită din datele primite nu trebuie să provoace automat prăbușirea programului. Agentul trebuie să recunoască situația și să ajungă într-o stare controlată.
10.5 Trimitere solicitată, dar neaprobată
./src/lesson02_invoice_agent.py \
--input data/inbox/invoice_approved.json \
--request-send \
--trace-file logs/lesson02/requested.jsonl \
| jq '{status, actions_taken, stop_reason}'
Ce trebuie să vezi: waiting_for_approval. Agentul calculează și pregătește pachetul, dar acțiunea simulate_send nu apare.
Ce demonstrează: intenția de a face o acțiune nu este aceeași cu permisiunea de a o executa.
10.6 Trimitere solicitată și aprobată
./src/lesson02_invoice_agent.py \
--input data/inbox/invoice_approved.json \
--request-send \
--approve-send \
--trace-file logs/lesson02/approved.jsonl \
| jq '{status, actions_taken, calculated_totals, simulated_send_record, stop_reason}'
Ce trebuie să vezi: completed, acțiunea simulate_send și totalul 726.00. simulated_send_record înregistrează acțiunea simulată.
Ce demonstrează: aceeași capacitate poate fi blocată sau permisă în funcție de autoritate. Chiar și aici nu există efect extern real; programul scrie doar o înregistrare în propria stare.
10.7 Citește jurnalul execuției, nu doar rezultatul final
jq '{step, action: .selected_action, status: .status_after_action}' \
logs/lesson02/complete.jsonl
Fiecare linie JSONL reprezintă un pas complet. Uită-te la ordinea acțiunilor și compar-o cu schema din Secțiunea 8. Jurnalul este dovada că agentul a trecut printr-o succesiune de observații și decizii, nu a „sărit” direct la rezultatul final.
11. Testează regulile, nu doar exemplele
Rulările manuale ne ajută să înțelegem comportamentul, dar nu sunt suficiente pentru întreținerea proiectului. Testele automate transformă regulile importante în verificări care pot fi repetate după fiecare modificare.
Cum se citește un test
Majoritatea testelor urmează trei pași: pregătește o situație cunoscută, rulează funcția sau agentul, apoi compară rezultatul cu ceea ce trebuia să se întâmple. În engleză vei întâlni frecvent formula Arrange → Act → Assert.
De exemplu, testul pentru client lipsă șterge customer, rulează agentul și verifică două lucruri: starea trebuie să fie WAITING_FOR_INPUT, iar calculate_totals nu trebuie să fi fost executat.
Suita de mai jos verifică atât traseele normale, cât și defecte care ar putea deveni vulnerabilități: aprobare falsă introdusă ca text, valori NaN, limită de pași și totaluri injectate de document.
from __future__ import annotations
import copy
import sys
import unittest
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from lesson02_invoice_agent import ( # noqa: E402
AgentState,
Status,
build_state,
run_agent,
)
RAW_CONFIG = {
"goal": "prepare_invoice_for_human_review",
"required_fields": ["seller", "customer", "invoice_number", "currency", "items"],
"tax_rate": "0.21",
"max_steps": 12,
"sending_requires_approval": True,
}
COMPLETE_INVOICE = {
"seller": {"name": "Furnizor", "tax_id": "RO1"},
"customer": {"name": "Client", "tax_id": "RO2"},
"invoice_number": "TEST-1",
"currency": "RON",
"items": [
{"description": "Serviciu", "quantity": "2", "unit_price": "100.00"}
],
}
def make_state(invoice: dict, **overrides) -> AgentState:
values = {
"goal": RAW_CONFIG["goal"],
"required_fields": list(RAW_CONFIG["required_fields"]),
"tax_rate": Decimal(RAW_CONFIG["tax_rate"]),
"max_steps": RAW_CONFIG["max_steps"],
"sending_requires_approval": RAW_CONFIG["sending_requires_approval"],
}
values.update(overrides)
return AgentState(invoice=copy.deepcopy(invoice), **values)
class InvoiceAgentTests(unittest.TestCase):
def test_complete_invoice_waits_for_human_review(self) -> None:
state = run_agent(make_state(COMPLETE_INVOICE))
self.assertEqual(state.status, Status.WAITING_FOR_REVIEW)
self.assertIsNotNone(state.review_package)
self.assertEqual(state.calculated_totals["total"], "242.00")
self.assertNotIn("calculated_totals", state.invoice)
def test_missing_customer_waits_for_input(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
del invoice["customer"]
state = run_agent(make_state(invoice))
self.assertEqual(state.status, Status.WAITING_FOR_INPUT)
self.assertIn("customer", state.missing_fields)
self.assertNotIn("calculate_totals", state.actions_taken)
def test_invalid_negative_price_is_blocked(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
invoice["items"][0]["unit_price"] = "-1"
state = run_agent(make_state(invoice))
self.assertEqual(state.status, Status.BLOCKED)
self.assertEqual(state.actions_taken, ["block_invalid_items"])
def test_non_finite_quantity_is_blocked(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
invoice["items"][0]["quantity"] = "NaN"
state = run_agent(make_state(invoice))
self.assertEqual(state.status, Status.BLOCKED)
self.assertIn("număr finit", " ".join(state.validation_errors))
def test_malformed_seller_blocks_instead_of_crashing(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
invoice["seller"] = "Furnizor introdus ca text"
state = run_agent(make_state(invoice))
self.assertEqual(state.status, Status.BLOCKED)
self.assertEqual(state.actions_taken, ["block_invalid_structure"])
self.assertIn("seller trebuie să fie un obiect JSON", state.validation_errors)
def test_send_request_without_approval_waits(self) -> None:
state = run_agent(make_state(COMPLETE_INVOICE, send_requested=True, approved=False))
self.assertEqual(state.status, Status.WAITING_FOR_APPROVAL)
self.assertNotIn("simulate_send", state.actions_taken)
def test_approved_send_is_only_simulated(self) -> None:
state = run_agent(make_state(COMPLETE_INVOICE, send_requested=True, approved=True))
self.assertEqual(state.status, Status.COMPLETED)
self.assertIn("simulate_send", state.actions_taken)
self.assertTrue(state.simulated_send_record["performed"])
self.assertNotIn("simulated_send", state.invoice)
def test_step_limit_stops_without_exceeding_limit(self) -> None:
state = run_agent(make_state(COMPLETE_INVOICE, max_steps=1))
self.assertEqual(state.status, Status.FAILED)
self.assertEqual(state.step_count, 1)
self.assertEqual(state.stop_reason, "a fost atins numărul maxim de pași")
def test_missing_data_has_priority_over_progress_actions(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
del invoice["customer"]
state = run_agent(make_state(invoice, send_requested=True, approved=True))
self.assertEqual(state.actions_taken, ["request_missing_information"])
self.assertEqual(state.status, Status.WAITING_FOR_INPUT)
def test_supplied_totals_and_approval_fields_are_ignored(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
invoice["calculated_totals"] = {
"subtotal": "1.00",
"tax": "0.21",
"total": "1.21",
}
invoice["send_requested"] = True
invoice["approved"] = True
state = run_agent(build_state(RAW_CONFIG, invoice))
self.assertIn("calculate_totals", state.actions_taken)
self.assertNotIn("simulate_send", state.actions_taken)
self.assertEqual(state.calculated_totals["total"], "242.00")
self.assertEqual(state.status, Status.WAITING_FOR_REVIEW)
self.assertIn("calculated_totals", state.ignored_input_fields)
self.assertIn("send_requested", state.ignored_input_fields)
self.assertIn("approved", state.ignored_input_fields)
def test_rounding_keeps_displayed_totals_consistent(self) -> None:
invoice = copy.deepcopy(COMPLETE_INVOICE)
invoice["items"] = [
{"description": "Fractional service", "quantity": "0.333", "unit_price": "1.00"}
]
state = run_agent(make_state(invoice))
self.assertEqual(state.calculated_totals["subtotal"], "0.33")
self.assertEqual(state.calculated_totals["tax"], "0.07")
self.assertEqual(state.calculated_totals["total"], "0.40")
def test_build_state_rejects_non_positive_max_steps(self) -> None:
config = {**RAW_CONFIG, "max_steps": 0}
with self.assertRaises(ValueError):
build_state(config, COMPLETE_INVOICE)
def test_build_state_rejects_negative_tax_rate(self) -> None:
config = {**RAW_CONFIG, "tax_rate": "-0.01"}
with self.assertRaises(ValueError):
build_state(config, COMPLETE_INVOICE)
def test_build_state_rejects_string_boolean_in_config(self) -> None:
config = {**RAW_CONFIG, "sending_requires_approval": "false"}
with self.assertRaises(ValueError):
build_state(config, COMPLETE_INVOICE)
def test_review_package_reflects_approval_rule(self) -> None:
state = run_agent(make_state(COMPLETE_INVOICE, sending_requires_approval=False))
self.assertFalse(state.review_package["approval_required_for_send"])
if __name__ == "__main__":
unittest.main()
Salvează codul în tests/test_lesson02_invoice_agent.py. Verifică mai întâi sintaxa, apoi rulează suita:
python -m py_compile tests/test_lesson02_invoice_agent.py
python -m unittest -v tests/test_lesson02_invoice_agent.py
Rezultatul corect este 15 teste trecute și linia finală OK. Dacă un test eșuează, citește numele lui înainte de traceback: numele spune ce regulă nu mai este respectată.
De ce există teste pentru lucruri care „par imposibile”?
Pentru că datele reale nu respectă întotdeauna intențiile programatorului. JSON-ul poate conține "false" ca text în loc de false ca valoare booleană; un număr poate deveni NaN; o modificare viitoare poate schimba ordinea regulilor. Testele fixează explicit comportamentul dorit și fac aceste presupuneri vizibile.
Ce vulnerabilitate previne testul totalurilor furnizate de document?
Previne situația în care factura introduce propriul câmp calculated_totals, iar mecanismul de control consideră în mod greșit că verificarea a fost deja executată. Versiunea corectă elimină acel câmp din copia sursei și recalculează totalurile în starea agentului.
12. Desenează aceeași buclă cu Graphviz
Până aici ai văzut bucla în cod și în jurnal. Acum o reprezentăm vizual. Scopul nu este decorativ: dacă diagrama și codul descriu procese diferite, documentația nu mai corespunde implementării.
Cum citești un fișier DOT
digraph începe un graf orientat. Linii precum goal -> observe creează săgeți. Nodul boundary are formă de romb deoarece reprezintă o decizie: agentul continuă, așteaptă un om sau se oprește.
digraph InvoiceAgent {
rankdir=LR;
graph [fontname="DejaVu Sans", bgcolor="white", pad="0.35", nodesep="0.45", ranksep="0.7"];
node [shape=box, style="rounded,filled", fillcolor="#f5f2ea", color="#174a45", fontcolor="#173632", fontname="DejaVu Sans", penwidth=1.4, margin="0.14,0.10"];
edge [fontname="DejaVu Sans", color="#46645f", fontcolor="#284843", penwidth=1.2];
goal [label="Obiectiv\nPregătește factura pentru verificare"];
observe [label="Observă\nCâmpuri lipsă, structură, stare"];
decide [label="Decide\nAlege o singură acțiune permisă"];
act [label="Acționează\nValidează, calculează, pregătește sau cere date"];
evaluate [label="Evaluează\nÎnregistrează rezultatul și noua stare"];
boundary [shape=diamond, fillcolor="#efe4c7", label="Continuă?"];
wait [label="Așteaptă\nDate, verificare sau aprobare"];
stop [label="Oprire\nFinalizat, blocat sau eșuat"];
goal -> observe;
observe -> decide;
decide -> act;
act -> evaluate;
evaluate -> boundary;
boundary -> observe [label="da"];
boundary -> wait [label="este nevoie de om"];
boundary -> stop [label="nu"];
}
Salvează textul în diagrams/lesson02_invoice_agent.dot, apoi generează imaginea:
dot -Tpng -Gdpi=160 diagrams/lesson02_invoice_agent.dot -o diagrams/lesson02_invoice_agent.png
xdg-open diagrams/lesson02_invoice_agent.png
Exercițiu de verificare imediată: compară imaginea cu logs/lesson02/complete.jsonl. Pentru factura completă, jurnalul trebuie să urmeze o ramură posibilă din diagramă și să se termine în așteptarea verificării umane.
13. Proiectează bucla propriului agent
Exemplul facturii este doar un caz. Acum transferi aceeași structură către agentul pe care vrei să-l construiești. Nu alege încă un model sau un cadru software (framework); mai întâi definește problema, mediul, acțiunile și limitele.
Completează fișa în docs/lessons/lesson-02/agent-loop-design.md. Fiecare secțiune răspunde la o întrebare necesară pentru arhitectură:
- Obiectiv: ce stare finală trebuie atinsă?
- Dovada finalizării: ce fapt observabil ne permite să spunem „gata”?
- Mediu și observații: de unde vin informațiile și ce poate vedea agentul?
- Stare: ce trebuie păstrat între doi pași?
- Acțiuni și instrumente: ce poate face efectiv?
- Aprobări și interdicții: ce nu are voie să facă singur?
- Oprire și limite: când se termină, când așteaptă și când declară eșec?
cat > docs/lessons/lesson-02/agent-loop-design.md <<'EOF'
# Lecția 2 — Proiectarea buclei agentului
## Nume de lucru
## Utilizatorul pentru care este construit
## Obiectiv
## Dovada că obiectivul a fost atins
## Mediul în care operează
Enumeră sistemele externe, sursele de fișiere, aplicațiile, persoanele sau dispozitivele relevante.
## Observații disponibile
Enumeră informațiile pe care agentul le poate primi. Separă datele observate direct de interpretările făcute asupra lor.
## Starea care trebuie păstrată
Enumeră informațiile necesare între pași. Separă datele-sursă nesigure de rezultatele calculate de agent.
## Acțiuni permise
## Acțiuni care necesită aprobare
## Acțiuni interzise
## Instrumente
Pentru fiecare instrument, notează intrările, ieșirile, efectele externe posibile, erorile și permisiunile necesare.
## Cum este aleasă acțiunea următoare
Reguli, mașină de stări, căutare, planificator, model sau combinație.
## Condiții de așteptare
## Condiții de escaladare către un om
## Condiții de finalizare
## Condiții de eșec
## Limite de resurse
Număr maxim de pași, reîncercări, timp, cost și apeluri de instrumente.
## Informații pentru audit
Ce trebuie înregistrat pentru ca o altă persoană să poată reconstrui rularea?
EOF
Exemplu scurt: cum ai completa „Dovada că obiectivul a fost atins”?
Dacă obiectivul ar fi „pregătește o factură pentru verificarea contabilului”, o dovadă bună ar fi: „există un pachet de verificare care conține părțile, pozițiile validate, totalurile recalculate și lista problemelor rămase, iar starea este waiting_for_review”. Formularea este mai bună decât „agentul a terminat”, deoarece poate fi verificată din date.
14. Înregistrează decizia de arhitectură — ADR-0002
În Laboratorul 1 ai introdus primul ADR. Continuăm aceeași practică. Un Architecture Decision Record (ADR) nu descrie tot proiectul; păstrează o singură decizie importantă împreună cu motivul și consecințele ei.
Decizia acestei lecții este: starea activă a agentului trebuie să fie explicită și inspectabilă, iar rezultatele derivate trebuie separate de documentul-sursă. Creează fișierul:
cat > docs/architecture-decisions/ADR-0002-explicit-agent-state.md <<'EOF'
# ADR-0002: Păstrăm starea agentului explicită și inspectabilă
## Stare
Acceptată pentru proiectul cumulativ al cursului.
## Context
Un agent care lucrează în mai mulți pași trebuie să păstreze obiectivul, observațiile, acțiunile, rezultatele instrumentelor, rezultatele validării, erorile, aprobările și motivul opririi. Dacă aceste informații rămân ascunse, erorile sunt greu de investigat, iar evaluarea devine nesigură.
Documentul analizat este o sursă externă de date și nu trebuie să poată pretinde că valorile derivate au fost deja calculate ori verificate și nici că o acțiune a fost aprobată.
## Decizie
Reprezentăm starea sarcinii active prin date structurate și stări explicite. Păstrăm totalurile calculate, rezultatele validării, pachetul pentru verificare, cererea de trimitere, aprobarea și acțiunile simulate separat de factura-sursă. Înregistrăm cronologic observațiile, acțiunile selectate și rezultatele.
## Consecințe
- Fiecare rulare poate fi inspectată și testată.
- Datele de intrare nu pot sări peste verificările agentului și nu se pot autoaproba.
- Sarcinile întrerupte pot fi reluate ulterior în mod deliberat.
- Vor fi necesare reguli suplimentare pentru stocare, identitate și protecția datelor.
- Structura stării trebuie modificată cu grijă pe măsură ce proiectul crește.
EOF
Observă structura: Context explică problema; Decizie spune ce alegem; Consecințe notează atât avantajele, cât și costurile. Un ADR bun nu încearcă să pretindă că alegerea nu are dezavantaje.
15. Verifică și salvează modificările cu Git
Nu facem commit imediat după ce am creat fișierele. Mai întâi verificăm ce vede Git și confirmăm că jurnalele de execuție rămân ignorate.
Dacă nu ai configurat încă identitatea Git pe acest calculator:
git config --global user.name "Numele tău"
git config --global user.email "email@example.com"
Pentru un repository public poți folosi o adresă de tip no-reply, dacă nu dorești să îți expui adresa personală.
Verificările înainte de commit
git status --shortarată fișierele noi sau modificate.git check-ignore -v ...trebuie să confirme că jurnalul este ignorat și să arate regula care îl ignoră.git diff --cached --stat, dupăgit add, arată ce va intra în commit.
git status --short
git check-ignore -v logs/lesson02/complete.jsonl
git add \
.gitignore \
configs/invoice_agent.json \
data/inbox/ \
src/lesson02_invoice_agent.py \
tests/test_lesson02_invoice_agent.py \
diagrams/lesson02_invoice_agent.dot \
diagrams/lesson02_invoice_agent.png \
docs/lessons/lesson-02/agent-loop-design.md \
docs/architecture-decisions/ADR-0002-explicit-agent-state.md
git diff --cached --stat
git commit -m "Build explicit Lesson 2 agent loop"
Ce trebuie să verifici înainte să confirmi commit-ul: fișierele din logs/lesson02/ nu trebuie să apară printre fișierele adăugate. Dacă apar, oprește-te și verifică .gitignore.
16. Exerciții practice — de la înțelegere la modificare
Exercițiile cresc gradual în dificultate. Fă primele trei înainte să consideri laboratorul terminat. Fiecare exercițiu cere o singură schimbare principală și are un indiciu sau un răspuns posibil ascuns.
16.1 Exercițiu de bază: prezice înainte să rulezi
Creează o copie a facturii complete în care unit_price este -1. Înainte de rulare, scrie pe o linie ce stare și ce primă acțiune te aștepți să vezi. Apoi rulează agentul și compară predicția cu rezultatul.
Răspuns posibil
Agentul ajunge în starea blocked, iar prima și singura acțiune este block_invalid_items. Validarea pozițiilor este verificarea care rulează înaintea calculului totalurilor: un preț negativ nu poate intra în aritmetică, oricât de convingător ar arăta documentul. Blocarea (blocked) este starea controlată în care agentul se oprește din cauza datelor, nu din cauza unui defect de program.
Pasul pregătitor creează copia cu prețul modificat, iar comanda de rulare arată exact asta pe Ubuntu 24.04, în folderul proiectului:
./src/lesson02_invoice_agent.py --input data/inbox/invoice_negative_price.json --trace-file logs/lesson02/negative.jsonl | jq '{status, actions_taken, validation_errors, stop_reason}' — comanda pornește agentul cu factura modificată, scrie jurnalul în fișierul indicat și jq afișează numai câmpurile care contează:
{
"status": "blocked",
"actions_taken": [
"block_invalid_items"
],
"validation_errors": [
"poziția 1: unit_price nu poate fi negativ"
],
"stop_reason": "pozițiile facturii sunt invalide"
}Compară predicția scrisă înainte de rulare cu acest rezultat: dacă ai prezis blocked și block_invalid_items, ai înțeles ordinea validării din choose_action().
Capcana: să „repari” prețul negativ în cod sau să aștepți ca agentul să îl corecteze — un preț negativ este o decizie pentru om, nu o eroare de tipar. Pentru constructorul de agenți, exercițiul arată de ce validarea rulează înaintea progresului: datele care nu pot fi folosite trebuie să oprească sarcina, nu să intre în calcule.
16.2 Exercițiu de validare: limitează monedele acceptate
Modifică programul astfel încât currency să accepte numai RON și EUR. Adaugă un singur test cu USD și verifică faptul că agentul se blochează înainte de calculate_totals.
Indiciu
Indiciul: regula pentru monedă aparține validării informațiilor generale ale facturii — funcției validate_metadata() — nu funcției calculate_totals(). Validarea metadatelor verifică forma și valorile câmpurilor de identificare (numărul facturii, moneda), în timp ce calculul are voie să pornească abia după ce acestea trec.
În practică, adaugi în validate_metadata() o condiție care respinge orice currency diferit de RON sau EUR, cu un mesaj în română care spune exact ce nu a fost acceptat; eroarea ajunge în lista validation_errors, iar observația o duce mai departe. Nu modifica choose_action(): mecanismul existent transformă deja erorile de validare în acțiunea block_invalid_metadata.
Testul cerut seamănă cu testul pentru client lipsă: copiezi factura completă, pui "currency": "USD", rulezi agentul și verifici două lucruri — starea este blocked, iar calculate_totals nu apare în actions_taken. Pentru constructorul de agenți, exercițiul arată regula de amplasare a unei reguli noi: validarea datelor stă înaintea pașilor care consumă progres, iar politica nu se scrie de două ori.
16.3 Exercițiu de modelare a stării: „verificat” nu înseamnă „aprobat”
Adaugă în AgentState un câmp separat care arată dacă un om a verificat pachetul. Nu modifica logica argumentului --approve-send. Scopul este să poți reprezenta simultan situația „pachet verificat, dar trimiterea încă neaprobată”.
De ce contează separarea?
Verificarea și aprobarea răspund la două întrebări diferite, iar starea agentului trebuie să le poată ține simultan. Verificarea răspunde la „este rezultatul corect?”, iar aprobarea răspunde la „are voie sistemul să execute această acțiune?”; prima este o judecată despre conținut, a doua este o decizie de autoritate.
În practică, adaugi în clasa AgentState un câmp separat — de pildă reviewed_by_human: bool — care este pus pe True doar de fluxul de verificare a pachetului, fără să atingi logica argumentului --approve-send. Situația cerută devine atunci reprezentabilă: reviewed_by_human este True, iar aprobarea trimiterii este încă False — pachetul este corect, dar sistemul nu are voie să trimită.
Capcana: să reutilizezi câmpul de aprobare pentru verificare, „fiindcă oricum vine tot un om”. Rolurile pot aparține unor persoane diferite — contabilul verifică, managerul aprobă — iar confundarea lor face ca o simplă verificare să deblocheze o trimitere. Pentru constructorul de agenți, exercițiul fixează principiul din lecție: deciziile de naturi diferite primesc câmpuri diferite în stare, iar fiecare câmp are propria politică de schimbare.
16.4 Exercițiu de siguranță: strică intenționat ordinea regulilor
Într-o copie temporară a funcției choose_action(), mută verificarea aprobării înaintea verificării datelor lipsă. Rulează testul pentru clientul lipsă și observă ce comportament se schimbă. Apoi restaurează imediat ordinea corectă.
Ce trebuie să înțelegi
Ordinea regulilor este parte din comportament: dacă verificarea aprobării se mută înaintea verificării datelor lipsă, agentul poate avansa spre trimitere în timp ce informațiile obligatorii încă lipsesc. Ordinea regulilor este ordinea în care choose_action() își compară condițiile; prima regulă care se potrivește câștigă.
Demonstrația rulează pe scenariul client lipsă din secțiunea 10.2: cu ordinea corectă, agentul alege request_missing_information și ajunge în waiting_for_input. Cu verificarea mutată, același scenariu cu --request-send --approve-send ar lua mai întâi ramura de trimitere — exact greșeala pe care o prinde testul test_missing_data_has_priority_over_progress_actions, care cere ca singura acțiune executată să fie cererea informațiilor lipsă.
În practică, muți blocul de aprobare deasupra blocului de date lipsă într-o copie temporară a funcției, rulezi testul, vezi eșecul, apoi restaurezi imediat ordinea corectă.
Capcana: să lași copia modificată „până mâine”. Pentru constructorul de agenți, exercițiul arată de ce regulile de siguranță se evaluează înaintea pașilor de progres: o dată încălcată, ordinea transformă o lipsă de date într-o acțiune cu efect extern.
16.5 Extensie: o singură reîncercare controlată
Simulează o eroare temporară a instrumentului de calcul: prima apelare eșuează, a doua reușește. Permite maximum o reîncercare și scrie un test care confirmă că agentul se oprește sigur dacă și a doua încercare eșuează.
Indiciu de proiectare
Reîncercarea se controlează cu un contor în stare, nu cu o buclă infinită: numărul de încercări se păstrează în AgentState, iar politica permite maximum o reluare pentru erori temporare — indisponibilitățile scurte ale instrumentului de calcul — și zero pentru erori de argument.
În practică, adaugi în stare un câmp de tip calculation_retries; când apelul calculatorului eșuează cu o eroare temporară și contorul este sub limită, agentul incrementează contorul și alege din nou calculul; la al doilea eșec, agentul intră în failed, cu motivul înregistrat. Testul cerut pregătește exact acest caz: instrumentul eșuează de două ori, iar agentul se oprește sigur — starea finală este failed, nu o blocare a interpretorului.
Avantajul contează pentru jurnal: write_trace() înregistrează fiecare pas, deci reîncercarea apare ca pas cu acțiune și motiv, iar cineva care citește trace-ul poate explica de ce s-a repetat acțiunea și de ce s-a oprit. Capcana: să pui while True în jurul apelului — bucla fără memorie repetă la nesfârșit exact acțiunea care eșuează. Pentru constructorul de agenți, exercițiul arată forma generală a reîncercării: contor, limită, stare și dovadă scrisă.
16.6 Extensie de documentație
Adaugă în diagrama DOT noduri pentru Configurație, AgentState, Jurnal și Aprobare umană. Generează din nou imaginea PNG și verifică dacă săgețile descriu relațiile reale din cod.
17. Depanare: pornește de la simptom
Când ceva nu merge, nu repeta toate comenzile și nu adăuga sudo la întâmplare. Citește mesajul complet, verifică folderul curent și identifică etapa la care ai ajuns.
cd: No such file or directory
Ce vezi: comanda cd ~/ai-agents-zero-to-expert refuză cu mesajul exact cd: No such file or directory.
Ce înseamnă: folderul proiectului nu există la calea tastată — fie nu a fost creat încă, fie numele are o diferență (o literă, un spațiu, alt utilizator). Comanda cd nu creează foldere; doar se mută în cele existente.
Cum confirmi: rulează ls -la ~ și caută în listă folderul proiectului. Ieșire reală pe Ubuntu 24.04 (primele rânduri; conținutul exact al folderului tău personal va diferi):
ls -la ~ | head -4
total 16752
drwxr-x--- 59 ai ai 4096 oct 16 02:41 .
drwxr-xr-x 6 root root 4096 mai 30 14:30 ..
drwxrwxr-x 3 ai ai 4096 iun 29 10:01 .agentsCum repari: creează folderul cu mkdir -p ~/ai-agents-zero-to-expert și intră în el.
Cum verifici reparația: rulează cd ~/ai-agents-zero-to-expert && pwd; comanda pwd trebuie să afișeze exact folderul proiectului, iar de aici pașii din secțiunea 3 funcționează. Capcana: să creezi folderul în alt loc decât cel la care fac referire restul comenzilor din laborator — drumul scurt este mereu ~ plus numele din lecție.
.venv/bin/activate lipsește
Ce vezi: comanda source .venv/bin/activate eșuează cu No such file or directory, deși te afli în folderul proiectului.
Ce înseamnă: mediul virtual nu a fost creat în acest folder — .venv nu există, sau a fost creat în alt folder. Mediul virtual este un folder cu propriul interpretor Python; el nu vine cu proiectul, se creează la Pasul 4.
Cum confirmi: rulează ls -a și uită-te după .venv. Ieșire reală dintr-un proiect proaspăt despachetat, înainte de Pasul 4:
ls -a
. .. .gitignore configs data diagrams docs logs src testsNiciun .venv în listă — mediul chiar lipsește.
Cum repari: rulează python3 -m venv .venv, apoi source .venv/bin/activate.
Cum verifici reparația: după activare, which python3 trebuie să afișeze o cale în interiorul lui .venv, nu /usr/bin/python3 — asta arată că shell-ul folosește mediul proiectului. Capcana: să creezi mediul cu sudo sau într-un folder superior; ambele produc un mediu pe care pașii următori nu îl văd.
Permission denied când rulezi scriptul
Ce vezi: comanda ./src/lesson02_invoice_agent.py … refuză cu Permission denied, deși fișierul există.
Ce înseamnă: fișierul nu are bitul de executare. Un fișier nou copiat sau despachetat din arhivă nu are voie să pornească direct din shell până când permisiunea nu este acordată explicit cu chmod.
Cum confirmi: rulează ls -l src/lesson02_invoice_agent.py. În ieșirea reală a arhivei proaspăt despachetate, pe primul grup de zece caractere nu apare x:
ls -l src/lesson02_invoice_agent.py
-rw-r--r-- 1 ai ai 19763 oct 16 12:04 src/lesson02_invoice_agent.pyCum repari: rulează chmod +x src/lesson02_invoice_agent.py.
Cum verifici reparația: aceeași comandă ls -l arată acum -rwxr-xr-x, iar ./src/lesson02_invoice_agent.py --help afișează mesajul de ajutor în loc de eroare. Capcana: chmod 777 „ca să meargă sigur” — dă tuturor totul, exact inversul permisiunilor minime din lecție; alternativa mereu disponibilă rămâne python3 src/lesson02_invoice_agent.py.
BrokenPipeError la o comandă care folosește | jq ...
Ce vezi: traceback-ul se termină cu BrokenPipeError într-o comandă care folosește | jq ….
Ce înseamnă: programul din dreapta conductei (pipe) s-a închis înainte ca Python să termine de scris; în acest laborator, cauza cea mai frecventă este că jq nu este instalat sau nu poate porni, deci nimeni nu mai citește din conductă.
Cum confirmi: rulează jq --version. Ieșire reală pe Ubuntu 24.04 cu jq instalat:
jq --version
jq-1.7Dacă primești jq: command not found, diagnosticul este confirmat.
Cum repari: reia Pasul 2 și instalează jq cu sudo apt install -y jq.
Cum verifici reparația: rulează din nou comanda din secțiunea 10.1, cu conducta completă; acum jq afișează câmpurile stării și eroarea nu mai apare. Capcana: să citești BrokenPipeError ca pe o eroare a agentului — Python a funcționat; problema este la cealaltă parte a conductei, iar o expresie jq invalidă poate produce același simptom.
jq raportează eroare JSON
Ce vezi: jq se oprește cu un mesaj despre sintaxa JSON în loc să afișeze datele.
Ce înseamnă: fișierul sau textul care intră în jq nu este JSON valid — de obicei o virgulă în plus sau în minus, ghilimele simple în loc de ghilimele duble, ori o acoladă neînchisă. JSON acceptă numai ghilimele duble pentru șiruri și nume de câmpuri.
Cum confirmi: rulează jq . data/inbox/invoice_complete.json pe un fișier cunoscut ca fiind bun. Ieșirea reală începe cu obiectul afișat frumos:
jq . data/inbox/invoice_complete.json | head -3
{
"seller": {
"name": "Example Studio SRL",Dacă aceasta funcționează, problema este în fișierul pe care îl testai, nu în jq.
Cum repari: deschide fișierul acuzat și corectează virgulele, ghilimelele și acoladele la mesajul indicat de jq, care numește linia și coloana.
Cum verifici reparația: jq . fișierul-tău.json trebuie să afișeze conținutul fără mesaj de eroare; dacă sintaxa e bună dar datele tot nu se potrivesc, problema este structura așteptată de agent, nu JSON-ul.
dot: command not found
Ce vezi: shell-ul răspunde dot: command not found la comanda de generare a diagramei.
Ce înseamnă: programul dot — partea Graphviz care transformă fișierele DOT în imagini — nu este instalat sau nu este pe PATH, lista de foldere în care shell-ul caută comenzile.
Cum confirmi: rulează which dot; fără instalare, comanda nu afișează nimic și întoarce cod de eroare. După instalare, ieșirea reală a verificării este:
dot -V
dot - graphviz version 2.43.0 (0)Cum repari: sudo apt install -y graphviz, apoi rulează din nou comanda dot -Tpng … -o diagrams/lesson02_invoice_agent.png din secțiunea 12.
Cum verifici reparația: fișierul diagrams/lesson02_invoice_agent.png există și are o dată de modificare recentă, vizibilă cu ls -l diagrams/; imaginea deschisă arată bucla din diagramă. Capcana: să editezi doar imaginea PNG — ea este un rezultat; sursa este fișierul DOT, iar Graphviz este cel care o regenerează.
--approve-send este respins
Ce vezi: agentul se oprește cu starea failed și un mesaj despre combinația greșită de opțiuni. Ieșire reală, redusă la ultimele câmpuri:
./src/lesson02_invoice_agent.py --input data/inbox/invoice_approved.json --approve-send | jq '{status, error}'
{
"status": "failed",
"error": "--approve-send are sens numai împreună cu --request-send"
}Ce înseamnă: aprobarea este răspunsul la o cerere; fără --request-send, nu există nicio cerere de trimitere pe care cineva să o aprobe, iar agentul refuză combinația în loc să o ignore.
Cum confirmi: citește câmpul error din ieșire — el numește exact regula încălcată; nu este un accident, este politica din cod.
Cum repari: folosește cele două opțiuni împreună: --request-send --approve-send.
Cum verifici reparația: cu ambele opțiuni, starea finală este completed și simulated_send_record apare în ieșire (secțiunea 10.6); doar cu --request-send, starea este waiting_for_approval (secțiunea 10.5). Capcana: să crezi că --approve-send „poate ajută” singur — aprobarea fără cerere nu este permisivitate, este o stare care nu are sens.
Un test eșuează, dar programul „pare” să funcționeze
Ce vezi: rulările manuale arată stări corecte, dar python3 -m unittest … raportează FAILED cu un nume de test.
Ce înseamnă: una dintre regulile fixate de teste nu mai este respectată — de obicei după o modificare de care nu ți-ai amintit. Un rezultat manual corect într-un scenariu nu garantează că toate celelalte reguli au rămas la locul lor; exact pentru asta există suita.
Cum confirmi: rulează suita doar pentru acest fișier, cu nume de test afișate. Ieșirea reală a suitei sănătoase, pe Ubuntu 24.04:
python3 -m unittest -v tests/test_lesson02_invoice_agent.py 2>&1 | tail -3
----------------------------------------------------------------------
Ran 15 tests in 0.002s
OKLa o regresie, în loc de OK apar FAIL și numele verificării — numele spune ce regulă a căzut.
Cum repari: compară regula testată cu modificarea ta recentă și decide cine are dreptate; de regulă, testul.
Cum verifici reparația: aceeași comandă se încheie din nou cu OK pentru toate cele 15 teste. Capcana: să ignori testul „fiindcă programul merge” — testul este înregistrarea scrisă a unei decizii de proiectare; ștergerea lui pierde decizia, nu doar verificarea.
18. Checklist de finalizare
Bifează un punct numai după ce ai rulat comanda sau ai verificat comportamentul descris. Lista confirmă că proiectul poate fi reluat în lecția următoare fără pași lipsă.
- Pot explica de ce agentul are
AgentStateseparat de factura-sursă. - Pot urmări în cod traseul
observe() → choose_action() → execute_action(). - Factura completă se oprește în
waiting_for_review. - Clientul lipsă produce
waiting_for_inputînainte de orice calcul. - Structura greșită produce
blocked, nu traceback. - Totalurile și aprobările introduse de document sunt ignorate.
- Trimiterea simulată cere separat
--request-sendși, când politica o cere,--approve-send. - Pot explica diferența dintre „trimitere solicitată” și „trimitere aprobată”.
- Toate cele 15 teste se termină cu
OK. - Pot citi jurnalul JSONL și identifica acțiunea fiecărui pas.
- Diagrama PNG a fost generată și corespunde buclei din cod.
- Jurnalele din
logs/sunt ignorate de Git. - Am completat fișa buclei propriului agent și ADR-0002.
19. Ce ai construit de fapt
La final nu ai doar un script care calculează o factură. Ai un exemplu mic de agent determinist cu stare explicită și buclă de control: urmărește un obiectiv, observă situația, selectează acțiuni în funcție de stare, primește rezultatul acțiunii și repetă procesul până ajunge într-o condiție de oprire.
Nu este încă un agent bazat pe LLM și nu este autonom în sens larg. Tocmai acesta este avantajul didactic: poți vedea unde se află controlul înainte să introducem un model probabilistic. În lecțiile următoare vom putea înlocui sau completa anumite decizii cu modele fără să pierdem regulile deterministe, aprobările, jurnalul și testele.