Μετάβαση στο περιεχόμενο
23/30Κεφάλαιο 23 από 30

Φτιάξτε ένα agent harness: το loop και οι πέντε έξοδοί του

Ένα loop δεκαπέντε γραμμών που πετυχαίνει με την πρώτη, μετά σπάει επίτηδες επτά φορές — με runaway κόστος 77×.

Σε αυτή τη σελίδα

Ξεκινήστε από το ειλικρινές κομμάτι, επειδή κανείς άλλος δεν θα το πει: το "harness" είναι jargon, όχι πρότυπο. Δεν υπάρχει προδιαγραφή, δεν υπάρχει επιτροπή, δεν υπάρχει ορισμός αναφοράς. Τα τέσσερα papers που παραθέτει αυτό το κεφάλαιο — ReAct,1 CoALA,2 SWE-bench και vLLM — δεν χρησιμοποιούν τη λέξη ούτε μία φορά στις περιλήψεις τους. Η πιο πολυκατεβασμένη υλοποίηση του πράγματος, το πακέτο ai της Vercel με 89,4 εκατομμύρια downloads τον μήνα, επίσης δεν τη χρησιμοποιεί: το string harness εμφανίζεται μηδέν φορές στα 397 KB των type declarations που διανέμει η έκδοση 7.0.93.3 Το ένα σημείο όπου η λέξη όντως σηκώνει βάρος σημαίνει κάτι εντελώς άλλο. Το SWE-bench λέει "harness" πέντε φορές στο README του, πάντα ως evaluation harness — το containerised scaffold που εφαρμόζει ένα patch και τρέχει τα tests — και το Python module του είναι κυριολεκτικά swebench.harness.run_evaluation.4

Άρα δύο διαφορετικά πράγματα μοιράζονται ένα όνομα. Ένα evaluation harness κρατά το agent ακίνητο και το βαθμολογεί. Ένα agent harness είναι το πρόγραμμα που τρέχει το agent: καλεί το model, εκτελεί ό,τι ζητά το model, αποφασίζει πότε να σταματήσει και κρατά το state ενδιάμεσα. Αυτό το κεφάλαιο χτίζει το δεύτερο, σε λιγότερες από διακόσιες γραμμές TypeScript, χωρίς κανένα framework.

Το ίδιο το loop είναι δεκαπέντε γραμμές και λειτουργεί με την πρώτη προσπάθεια. Όλα μετά από αυτό είναι ένας τρόπος να φύγετε από αυτό.

Εμφάνιση λεπτομερειών

Τι χρειάζεται αυτό το κεφάλαιο από τα προηγούμενα.

  • Κεφάλαιο 14 για τον client: deadlines, status triage, cancellation, idempotency keys και την τεχνική mock provider που χρησιμοποιείται ξανά εδώ.
  • Κεφάλαιο 16 για την αριθμητική: τα input tokens αυξάνονται με το τετράγωνο της συζήτησης, και οι τιμές που χρησιμοποιούνται παρακάτω είναι αυτές που διαβάστηκαν εκεί στις 6 Σεπτεμβρίου 2026.
  • Κεφάλαιο 18 για τον κατάλογο εργαλείων: ένα schema που βλέπει το model, ένα endpoint που δεν βλέπει ποτέ, και τον κανόνα ότι τα σφάλματα είναι context και όχι exceptions.
  • Κεφάλαιο 22 για το loop που κληρονομεί αυτό εδώ, και για τους δύο δημοσιευμένους ορισμούς του "agent" που διαφωνούν μεταξύ τους.

Δεν έχει tensors εδώ. Αυτό είναι το δεύτερο dependency hub του μαθήματος: τα Κεφάλαια 24, 25, 29 και 30 τρέχουν πάνω στο αρχείο παρακάτω, και τα 26 έως 28 χτίζουν πάνω σε όσα μπορεί να φτάσει.

Το Κεφάλαιο 14 δεν μπορούσε να γραφτεί απέναντι σε πραγματικό provider, επειδή δεν μπορείτε να ζητήσετε από έναν 429 σε επιλεγμένη στιγμή. Αυτό το κεφάλαιο έχει το ίδιο πρόβλημα με άλλη μορφή: δεν μπορείτε να ζητήσετε από ένα πραγματικό model να ξεφύγει, ή να ζητήσει το ίδιο εργαλείο δύο φορές στη σειρά, κατά παραγγελία και αναπαραγώγιμα.

Έτσι το πρώτο πρόγραμμα είναι ένας scripted provider: ένα endpoint με το σχήμα ενός chat completions API του οποίου η απάντηση είναι συνάρτηση του δείκτη turn και του τι έχουν επιστρέψει τα εργαλεία μέχρι τώρα. Μετρά token με πραγματικό byte-pair encoder, άρα τα χρήματα παρακάτω είναι αριθμητική και όχι διακόσμηση.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

Δύο γραμμές κουβαλούν τον σχεδιασμό. Ο δείκτης turn παράγεται από τη συζήτηση, δεν κρατιέται σε μεταβλητή, άρα ο provider είναι stateless και ένα run μπορεί να σκοτωθεί και να συνεχιστεί απέναντί του. Και το recover διαβάζει τα αποτελέσματα των εργαλείων πριν αποφασίσει: ένα scripted model που διαβάζει το δικό του transcript είναι το ελάχιστο που χρειάζεται για να μετρήσετε αν το harness του έδωσε κάτι που άξιζε να διαβαστεί.

Ο κατάλογος είναι του Κεφαλαίου 18, τέσσερα εργαλεία σε τρία αρχεία: list_files, read_file, delete_file — σημειωμένο ως needsApproval — και scan_archive, που είναι αργό επίτηδες.

Αυτή είναι όλη η ιδέα, πριν από οποιοδήποτε από τα μέρη που την κάνουν βιώσιμη.

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

Στρέψτε το στον scripted provider και κάνει ακριβώς αυτό που φαίνεται ότι κάνει:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

Τρία turns, δύο εκτελέσεις εργαλείων, ένα τέταρτο του αμερικανικού cent. Προσέξτε την τελευταία γραμμή: 204, 269, 342. Κάθε turn ξαναστέλνει όλα όσα προηγήθηκαν, που είναι ο τετραγωνικός λογαριασμός του Κεφαλαίου 16 να φτάνει σε ένα σημείο όπου κανείς δεν πληκτρολόγησε τίποτα. Το υπόλοιπο αυτού του κεφαλαίου είναι ό,τι συμβαίνει όταν αυτή η γραμμή δεν σταματά να μεγαλώνει.

Στρέψτε το ίδιο loop στο script runaway — ένα model που ζητά ένα εργαλείο σε κάθε turn και δεν εκπέμπει ποτέ πρόζα — και το σημειωμένο return δεν ενεργοποιείται ποτέ. Δεν υπάρχει άλλη έξοδος. Το πρόγραμμα τρέχει μέχρι να πεθάνει το process ή η πιστωτική κάρτα.

Η διόρθωση είναι μία γραμμή, είναι ο πρώτος έλεγχος που συνιστά η βιβλιογραφία,5 και όλοι τελικά τον γράφουν. Αυτό που σχεδόν κανείς δεν κάνει είναι να μετρήσει πόσο αξίζει:

turn capmodel callsinput tokensκόστος
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Διαβάστε τις δύο τελευταίες σειρές μαζί. Ο διπλασιασμός του cap από 50 σε 100 δεν διπλασίασε το κόστος· το πολλαπλασίασε επί 3,7. Τα input tokens πήγαν από 88.649 σε 337.299, συντελεστής 3,8, επειδή το turn nn κουβαλά κάθε προηγούμενο turn μαζί του και το σύνολο είναι Θ(n2)\Theta(n^2). Ένα turn cap δεν είναι γραμμικός επιλογέας. Είναι επιλογέας πάνω στην τετραγωνική ρίζα της χειρότερης περίπτωσής σας, γι’ αυτό το να το ανεβάσετε από 20 σε 100 "για σιγουριά" είναι απόφαση που αξίζει να κοστολογηθεί πριν την πάρετε.

Σπάσιμο δύο: ένα cap στα turns δεν είναι cap στα χρήματα

Σύνδεσμος στην ενότητα: Σπάσιμο δύο: ένα cap στα turns δεν είναι cap στα χρήματα

Το πρόβλημα με ένα turn cap είναι ότι ένα turn δεν έχει σταθερή τιμή. Είκοσι turns πάνω σε σύντομο transcript κόστισαν $0.038 παραπάνω. Είκοσι turns με κατάλογο 200 εργαλείων, ένα σύνολο ανακτημένων εγγράφων και σαράντα μηνύματα ιστορικού κοστίζουν εκατοντάδες φορές τόσο, και το cap δεν το ξέρει. Αυτό που θέλει να οριοθετήσει ο operator είναι ο λογαριασμός.

Έτσι το loop μετρά χρήματα, χρησιμοποιώντας το computeCost του Κεφαλαίου 16 απέναντι στις τιμές που διαβάστηκαν εκεί — $2.00 ανά εκατομμύριο input tokens και $12.00 ανά εκατομμύριο output, για το model που κοστολογείται σε όλο αυτό το μάθημα:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

Το ίδιο runaway script, χωρίς κανένα turn cap, τρία budgets:

budgetturns που έφτασεπραγματική δαπάνη
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Δύο πράγματα αξίζει να ονομαστούν. Πρώτον, το budget αγοράζει διαφορετικό αριθμό turns κάθε φορά, που είναι και το νόημα: οριοθετεί το πράγμα που νοιάζει τον operator και αφήνει τον αριθμό των turns να πέσει εκεί όπου τον βάζει το transcript. Δεύτερον, κάθε σειρά ξεπερνά το όριο. Το budget ήταν $0.010 και δαπανήθηκαν $0.010780, επειδή ο έλεγχος τρέχει πριν από ένα turn και η τιμή ενός turn δεν είναι γνωστή μέχρι να τελειώσει. Δεν μπορείτε να οριοθετήσετε τη δαπάνη ακριβώς· μπορείτε να την οριοθετήσετε εντός κόστους ενός turn. Πείτε το στη διεπαφή αντί να προσποιείστε, και βάλτε τον έλεγχο πριν από την κλήση ώστε η υπέρβαση να είναι ένα turn και όχι δύο.

Μέχρι τώρα το loop έχει τρεις εξόδους, και το σχήμα του υπόλοιπου κεφαλαίου είναι ορατό. Ένα production run τελειώνει ακριβώς με έναν από πέντε τρόπους, και δεν είναι παραλλαγές μεταξύ τους:

πώς τελειώνειποιος αποφάσισετι πρέπει να κάνει ο caller
το model σταμάτησε να ζητάτο modelδιαβάστε την απάντηση
turn capεσείς, εκ των προτέρωναυξήστε το cap ή δεχτείτε ένα μερικό αποτέλεσμα
budget exhaustedεσείς, εκ των προτέρωνεγκρίνετε περισσότερα χρήματα ή δεχτείτε ένα μερικό αποτέλεσμα
ένα σφάλμα που δεν μπορείτε να retryο provider ή ένα εργαλείοδιορθώστε το deployment· αποφασίζει το triage του Κεφαλαίου 14
παρενέβη άνθρωποςένα άτομοπεριμένετε ετυμηγορία και μετά συνεχίστε

Η συμπίεση αυτών σε ένα boolean είναι το πιο συνηθισμένο σχεδιαστικό λάθος σε αυτό το αρχείο, και είναι ακριβό με συγκεκριμένο τρόπο: τρία από τα πέντε είναι resumable και δύο δεν είναι. Ένα agent που έφτασε το turn cap του έχει έγκυρο transcript, πραγματικό μερικό αποτέλεσμα και επόμενο βήμα· ένα agent που πήρε 401 δεν έχει τίποτα από αυτά. Άρα το harness καταγράφει τον λόγο ως data:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

Το Κεφάλαιο 18 τελείωσε με έναν ισχυρισμό χωρίς αριθμό: δώστε το σφάλμα ενός εργαλείου πίσω στο model ως αποτέλεσμα εργαλείου αντί να το κάνετε raise, και το model συνήθως αυτοδιορθώνεται. Να ο αριθμός.

Μία αποτυχία, τρεις πολιτικές. Το scripted model μαντεύει ένα αρχείο που δεν υπάρχει· το εργαλείο πετά no such file: timeout.log. Call list_files to see what exists.

τι κάνει το harness με το σφάλμαturnstool runsκόστοςτι πήρε ο χρήστης
το πετά έξω από το loop11$0.000756ένα stack trace
επιστρέφει Error: the tool failed.21$0.001462"Δεν μπόρεσα να διαβάσω το αρχείο, άρα δεν ξέρω."
επιστρέφει τι συνέβη πραγματικά43$0.003550"Το errors.log αναφέρει timeout."

Η τρίτη σειρά κοστίζει 4,7 φορές την πρώτη και είναι η μόνη που απαντά στην ερώτηση. Και η δεύτερη σειρά είναι η ενδιαφέρουσα, επειδή είναι αυτό που κάνουν στην πράξη οι περισσότερες codebases: το σφάλμα πιάστηκε, το loop επέζησε, το model ενημερώθηκε ότι κάτι απέτυχε και όχι τι, και τα παράτησε ευγενικά. Η διαφορά μεταξύ της δεύτερης και της τρίτης σειράς δεν είναι error handling. Είναι μια πρόταση γραμμένη για αναγνώστη.

Το harness λοιπόν αντιμετωπίζει ένα thrown tool ως data, και κάνει τη διατύπωση πολιτική:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

Το Κεφάλαιο 18 προειδοποίησε και για την άλλη πλευρά, και έχει κι αυτή τίμημα. Στρέψτε το loop σε ένα εργαλείο που αποτυγχάνει για λόγο που κανένα μήνυμα δεν μπορεί να διορθώσει — μια ανάγνωση που το process δεν επιτρέπεται να εκτελέσει — και το model την κάνει retry για πάντα:

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

Έντεκα ίδιες εκτελέσεις μιας κλήσης που δεν μπορεί να πετύχει, 5,2 φορές το κόστος του run που ανέκαμψε από μία διορθώσιμη αποτυχία, και τίποτα στο τέλος. Τα σφάλματα είναι context· ένα μόνιμο σφάλμα είναι context που δηλητηριάζει το υπόλοιπο run. Η διάκριση είναι το status triage του Κεφαλαίου 14 μετακινημένο ένα επίπεδο πάνω: ένα σφάλμα πάνω στο οποίο μπορεί να δράσει το model επιστρέφει στο transcript, και ένα σφάλμα πάνω στο οποίο δεν μπορεί πρέπει να σταματά το run με λόγο. Το turn cap είναι αυτό που στέκεται σήμερα ανάμεσα σε εσάς και τη δεύτερη περίπτωση, που είναι πάτωμα και όχι διόρθωση.

Τώρα η αποτυχία που οι περισσότεροι υποθέτουν ότι δεν μπορεί να συμβεί. Τα models επαναλαμβάνονται. Ζητήστε από οποιοδήποτε loop να τρέξει αρκετά και θα δείτε το ίδιο εργαλείο με τα ίδια arguments σε δύο συνεχόμενα turns.

Μετρημένο απέναντι στο baseline της ίδιας εργασίας χωρίς την επανάληψη:

turnstool runsκόστος
η εργασία, χωρίς επανάληψη21$0.001396
η ίδια εργασία, μία κλήση επαναλαμβάνεται32$0.002446
επαναλαμβανόμενη, με result cache σε read-only εργαλεία31$0.002446

Η διπλή κλήση κόστισε $0.001050 επιπλέον, αύξηση 75 %, και εδώ είναι το σημείο που εκπλήσσει: το caching του αποτελέσματος δεν ανέκτησε τίποτα από αυτό. Το deduplication έσωσε την εκτέλεση του εργαλείου και όχι το turn, επειδή μέχρι να παρατηρήσει ο κώδικάς σας την επανάληψη, το model έχει ήδη πληρωθεί για το αίτημα. Η εξοικονόμηση είναι πραγματική όταν το εργαλείο είναι αργό, rate-limited ή χρεώνεται ανά κλήση — και είναι μηδέν στο line item που μεγάλωσε.

Υπάρχει χειρότερη εκδοχή. Εφαρμόστε το ίδιο cache σε εργαλείο που γράφει, και η δεύτερη κλήση σιωπηλά δεν συμβαίνει:

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

Ποιο από αυτά είναι σωστό; Κανένα, γνωστά. Το protocol λέει ότι αυτές είναι δύο κλήσεις: κουβαλούν δύο διαφορετικές τιμές tool_call_id. Τα arguments λένε ότι μπορεί να είναι μία. Ένα harness που αποφασίζει συγκρίνοντας argument strings μια μέρα θα καταπιεί τη δεύτερη από δύο ίδιες, σκόπιμες χρεώσεις — και το Κεφάλαιο 14 έχει ήδη ονομάσει τον μόνο μηχανισμό που το λύνει τίμια, που είναι ένα idempotency key που παράγεται ανά λογική λειτουργία από το επίπεδο που ξέρει τι είναι η λειτουργία. Μέχρι να κουβαλά το εργαλείο ένα τέτοιο, το υπερασπίσιμο default είναι το read-only gate παραπάνω: κάντε cache στα reads, εκτελέστε τα writes, και αφήστε το idempotency του ίδιου του write να χειριστεί τα υπόλοιπα.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

Το script destructive απαριθμεί τα αρχεία και μετά ζητά να διαγράψει ένα που η εργασία δεν ανέφερε ποτέ. Τίποτα στο loop μέχρι τώρα δεν θα το σταματούσε.

Ένα εργαλείο σημειωμένο ως needsApproval δεν αποτυγχάνει και δεν προχωρά. Σταματά το run και επιστρέφει τον έλεγχο, με όλα όσα χρειάζεται ένας άνθρωπος για να αποφασίσει:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

Αυτός είναι όλος ο μηχανισμός, και ο λόγος που είναι return αντί για callback είναι η επόμενη ενότητα: ανάμεσα στη διακοπή και την ετυμηγορία, το process μπορεί να μην υπάρχει πια.

Αλλά πρώτα, η μέτρηση που κανείς δεν περιμένει. Μια απόρριψη δεν είναι απουσία αποτελέσματος — το transcript έχει ένα slot με κλειδί tool_call_id και κάτι πρέπει να μπει μέσα. Τρέξτε την ίδια απόρριψη δύο φορές, αλλάζοντας μόνο τι λέει αυτό το κάτι:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

Τίποτα δεν διαγράφηκε σε κανένα από τα δύο runs, και στο δεύτερο λέγεται στον χρήστη ότι διαγράφηκε. Το σύστημα δικαιωμάτων λειτούργησε τέλεια· η αναφορά είναι ψέμα. Είναι ο ίδιος μηχανισμός με τον πίνακα tool-error, φτάνοντας κάπου που έχει πολύ μεγαλύτερη σημασία — ένας άνθρωπος είπε όχι, η ενέργεια μπλοκαρίστηκε σωστά, και η σύνοψη του agent αντιφάσκει με την πραγματικότητα επειδή η άρνηση δεν γράφτηκε ποτέ εκεί όπου διαβάζει το model. Ο κανόνας που προκύπτει είναι σύντομος: ό,τι κι αν αποφασίσει ο κώδικάς σας για μια tool call, γράψτε την απόφαση στο transcript με λόγια. Το Κεφάλαιο 30 επιστρέφει σε αυτό από την πλευρά της ασφάλειας, όπου είναι η διαφορά ανάμεσα σε audit trail και μυθοπλασία.

Μια έγκριση παίρνει λεπτά ή ώρες. Ένα deploy παίρνει δευτερόλεπτα. Αν το run ζει σε μια τοπική μεταβλητή μέσα σε ένα HTTP request, κάθε restart είναι χαμένο run και κάθε έγκριση είναι race.

Άρα το run δεν είναι closure. Είναι ένα απλό serialisable object — messages, turn count, cost, status, interruption, η λίστα των approved call ids — και το loop είναι pure function πάνω του. Αυτός ο μοναδικός περιορισμός είναι που κάνει το persistence θέμα μίας γραμμής:

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

Το ερώτημα ορθότητας δεν είναι η αποθήκευση. Είναι τι συμβαίνει στην επιστροφή, και η αφελής απάντηση σας διπλοχρεώνει. Αν το process πέθανε αφού το model ζήτησε ένα εργαλείο αλλά πριν γραφτεί το αποτέλεσμα, ένα resume που ξεκινά καλώντας ξανά το model πληρώνει για ένα turn που ήδη έχει — και αν ξεκινά ξανατρέχοντας τα εργαλεία, εκτελεί ένα write δύο φορές.

Η διόρθωση είναι να κάνετε το loop να ξεκινά ρωτώντας το transcript τι εκκρεμεί:

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

Κάθε iteration στραγγίζει πρώτα το pending και ρωτά το model μόνο όταν δεν εκκρεμεί τίποτα. Το resume γίνεται το ίδιο code path με το κανονικό, και το ίδιο και η έγκριση — μια εγκεκριμένη κλήση είναι απλώς μια pending κλήση που πλέον επιτρέπεται να τρέξει. Σκοτώστε το process στη μέση της εργασίας και επανεκκινήστε το:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

Δύο εκτελέσεις εργαλείων σε δύο processes για μια εργασία που χρειάζεται δύο, και το τελικό κόστος είναι ίδιο με το run που δεν κατέρρευσε ποτέ. Το κόστος συσσωρεύεται μέσα από το restart επειδή ήταν στο state, όχι σε μεταβλητή.

Το scan_archive παίρνει τρία δευτερόλεπτα εδώ και αντιπροσωπεύει το εργαλείο που παίρνει τρία λεπτά στην παραγωγή. Δύο πράγματα λείπουν όσο τρέχει: ο χρήστης δεν έχει ιδέα ότι συμβαίνει κάτι, και το κουμπί Stop δεν κάνει τίποτα.

Και τα δύο έχουν την ίδια διόρθωση, και είναι το AbortSignal του Κεφαλαίου 14 σπρωγμένο ένα επίπεδο βαθύτερα. Το signal δεν είναι μόνο για το fetch — περνά μέσα στο εργαλείο, και ένα καλογραμμένο εργαλείο το τιμά:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

Δύο milliseconds από το κλικ μέχρι τη διακοπή, επειδή το sleep μέσα στο εργαλείο ακούει το ίδιο signal που ακούει και το fetch. Περάστε το μόνο μέσα στο fetch και το ίδιο κουμπί Stop περιμένει τρία δευτερόλεπτα — το μήκος του εργαλείου — και το run "ακυρώνεται" αφού η δουλειά που ακύρωνε έχει ήδη τελειώσει. Cancellation που δεν είναι plumbed μέχρι κάτω είναι ένα spinner που λέει τη σωστή λέξη.

Το harness εκπέμπει μία γραμμή ανά event, και το λεξιλόγιο είναι αρκετά μικρό για να απομνημονευτεί: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

Τρεις ιδιότητες το κάνουν trace και όχι logging. Κάθε γραμμή κουβαλά το run id, άρα ένα run που απλώνεται σε τρία processes και δύο ημέρες είναι ένα query. Κάθε γραμμή turn κουβαλά τα δικά της token counts και το running cost, άρα το "γιατί αυτό το run κόστισε σαράντα δολάρια" μπορεί να απαντηθεί εκ των υστέρων αντί να είναι αναπαραγώγιμο μόνο θεωρητικά. Και το run_stopped κουβαλά τον λόγο, που είναι το πεδίο που μετατρέπει ένα support ticket σε απάντηση μίας γραμμής: ένα agent που σταμάτησε στο budget και ένα agent που κατέρρευσε φαίνονται ίδια απ’ έξω και χρειάζονται αντίθετες αντιδράσεις.

Το Κεφάλαιο 13 μέτρησε χρόνο μέχρι το πρώτο token σε hardware που σας ανήκει. Το Κεφάλαιο 14 τον μέτρησε μέσα από socket. Ένα agent τον πολλαπλασιάζει, και ο πολλαπλασιαστής είναι ένας αριθμός που κανείς δεν διάλεξε:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

Η ίδια εργασία τριών turns, αλλάζοντας μόνο το latency του provider:

provider latency ανά turnwall clock, 3 turns
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

Το ίδιο το harness συνεισφέρει δεκαπέντε milliseconds σε ένα run τριών turns. Όλα τα υπόλοιπα είναι NN πολλαπλασιασμένο με έναν αριθμό που δεν ελέγχετε — ορισμένο μέσα σε έναν serving scheduler που κάνει batching το request σας με requests αγνώστων6 — και το NN επιλέγεται από το model. Γι’ αυτό το streaming του Κεφαλαίου 14 έχει μεγαλύτερη σημασία εδώ απ’ ό,τι σε ένα chat και βοηθά λιγότερο: μπορείτε να κάνετε stream το τελικό turn, και τα τέσσερα turns πριν από αυτό είναι σιωπή εκτός αν το harness εκπέμπει πρόοδο. Είναι επίσης όλο το επιχείρημα για το event tool_progress παραπάνω — σε ένα agent, η ειλικρινής μονάδα feedback δεν είναι το token, είναι το βήμα.

Όλα παραπάνω έτρεξαν απέναντι σε scripted provider, που αποδεικνύει το harness και δεν αποδεικνύει τίποτα για models. Άρα αλλάξτε μία γραμμή — το seam από το Κεφάλαιο 14, LLM_BASE_URL — και στρέψτε τον ίδιο κώδικα σε ένα τοπικό Qwen2.5-0.5B-Instruct με τα ίδια τέσσερα εργαλεία. Έξι εργασίες πάνω στα ίδια τρία αρχεία:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

Τρία ευρήματα, και το τρίτο είναι ο λόγος που υπάρχει αυτή η ενότητα.

Κάθε μία εργασία τελείωσε σε ακριβώς δύο turns. Το turn cap δεν ενεργοποιήθηκε ποτέ, το budget δεν ενεργοποιήθηκε ποτέ, και η μόνη έξοδος του loop ήταν το model να παράγει πρόζα. Ένα model μισού δισεκατομμυρίου παραμέτρων δεν επαναλαμβάνει· απαντά στη δεύτερη ανάσα του είτε έχει ό,τι χρειάζεται είτε όχι. Το turn count είναι ιδιότητα του model, όχι του loop σας.

Το μέσο turn πήρε 6.908 milliseconds, άρα ο πίνακας latency παραπάνω δεν είναι παιχνίδι: σε αυτό το μέγεθος, ένα υποθετικό run οκτώ turns είναι σχεδόν ένα λεπτό wall clock χωρίς τίποτα στην οθόνη.

Και οι απαντήσεις είναι λάθος. Το μεγαλύτερο αρχείο είναι το errors.log· το model απαρίθμησε τα αρχεία, δεν τα διάβασε ποτέ και ονόμασε ένα έτσι κι αλλιώς. Η πρώτη εργασία μάντεψε όνομα αρχείου, ενημερώθηκε ότι δεν υπάρχει, και κατέληξε. Το harness εκτελέστηκε άψογα και στα έξι runs. Ένα harness κάνει ένα agent governable, όχι σωστό — το Κεφάλαιο 29 είναι το πώς μαθαίνετε ποιο από τα δύο συμβαίνει, και το Κεφάλαιο 30 είναι τι κοστίζει όταν κανείς δεν το έκανε.

Ένα εργαλείο στον κατάλογο μπορεί να έχει ένα άλλο run πίσω του. Η διεπαφή είναι του Κεφαλαίου 18 — ένα schema και ένα endpoint — και ένα ολόκληρο agent χωρά πίσω της επειδή αυτή η διεπαφή είναι στενή:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

Τρία πράγματα είναι ήδη σωστά σε αυτές τις δέκα γραμμές και και τα τρία είναι συνέπειες αποφάσεων που πάρθηκαν παραπάνω: το child έχει το δικό του window, άρα το transcript του parent λαμβάνει σύνοψη αντί για όλα όσα διάβασε το child· έχει τα δικά του limits, άρα ένα runaway child δεν μπορεί να ξοδέψει το budget του parent· και κληρονομεί το signal, άρα ένα Stop ακυρώνει το δέντρο. Το γιατί ένα καθαρό window είναι το νόημα και όχι side effect είναι στο Κεφάλαιο 24· τα πέντε orchestration patterns — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — και το handoff είναι στο Κεφάλαιο 25.

Πού βρίσκονται τα frameworks, και γιατί αυτό το μάθημα δεν χρησιμοποίησε ένα

Σύνδεσμος στην ενότητα: Πού βρίσκονται τα frameworks, και γιατί αυτό το μάθημα δεν χρησιμοποίησε ένα

Τίποτα από τα παραπάνω δεν πρέπει να διαβαστεί ως επιχείρημα κατά των βιβλιοθηκών. Μετρημένα στις 7 Σεπτεμβρίου 2026, για τον μήνα που έληξε στις 29 Αυγούστου:7

packagedownloads εκείνου του μήνατι σας δίνει
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, tool approval, step hooks
@anthropic-ai/claude-agent-sdk41.558.352το Claude Code harness ως βιβλιοθήκη: loop, sessions, hooks, permissions, subagents8
@langchain/langgraph12.812.815το loop ως explicit state graph
langchain11.359.058chains, agents, integrations
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, workflows, memory

Ο λόγος που αυτό το μάθημα γράφει το loop με το χέρι αντί να διδάξει ένα από αυτά δηλώνεται αντί να υπονοείται, και είναι μετρήσιμος. Στους δώδεκα μήνες έως τις 7 Σεπτεμβρίου 2026, το ai δημοσίευσε 945 εκδόσεις και μετακινήθηκε από major 5 σε major 7, και η agent class του εξακολουθεί να εξάγεται ως Experimental_Agent· το langchain δημοσίευσε 132 εκδόσεις στο ίδιο παράθυρο· το @openai/agents δημοσίευσε 83 και είναι ακόμη στο 0.x, δεκαπέντε μήνες μετά την πρώτη του κυκλοφορία.7 Ένα κεφάλαιο γραμμένο απέναντι σε οποιοδήποτε από αυτά τα APIs μπαγιατεύει μέσα σε μία σεζόν, και αυτό εδώ δημοσιεύεται σε τριάντα τρεις γλώσσες, άρα κάθε επανέκδοση κοστίζει όλη τη μετάφραση. Αυτό που βρίσκεται κάτω από όλα τους δεν μετακινείται: ένα loop, ένας κανόνας stopping, ένας κατάλογος, ένας executor, λίγο state.

Και η reference implementation συμφωνεί με αυτό το κεφάλαιο για το κομμάτι που έχει σημασία. Στην έκδοση 7.0.93 του ai, η έξοδος του loop δεν είναι αριθμός — είναι stopWhen, μια λίστα από predicates, από τα οποία το step count είναι απλώς ένα:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

Το stopping είναι πληθυντικό στην πιο χρησιμοποιημένη υλοποίηση αυτού του loop, για τον ίδιο λόγο που είναι πληθυντικό στις εκατόν ενενήντα έξι γραμμές παραπάνω.

Τώρα έχετε ένα harness: ένα loop, έναν κατάλογο, έναν executor, πέντε τρόπους εξόδου, ένα persisted run, ένα signal που φτάνει στα εργαλεία και ένα trace με run id σε κάθε γραμμή. Τα Κεφάλαια 24, 25, 29 και 30 χτίζουν πάνω σε αυτό το αρχείο, και τα 26 έως 28 πάνω σε όσα μπορεί να φτάσει.

Του μένει ένα πρόβλημα, και οι μετρήσεις παραπάνω το έδειχναν σε όλη τη διαδρομή. Κοιτάξτε άλλη μία φορά τον πίνακα runaway: 3.431 input tokens στα οκτώ turns, 337.299 στα εκατό. Κοιτάξτε το working run: 204, 269, 342. Κάθε turn ξαναστέλνει ολόκληρο το transcript, άρα το context ενός agent γεμίζει με το δικό του ιστορικό — και το model είναι χειρότερο στο να χρησιμοποιεί το μακρινό άκρο ενός μεγάλου window από το κοντινό, γι’ αυτό ένα καλό agent στο turn πέντε είναι μπερδεμένο στο turn σαράντα.

Ένα turn cap δεν το διορθώνει. Απλώς σας σταματά από το να πληρώνετε για να το βλέπετε να συμβαίνει. Αυτό που το διορθώνει είναι να αποφασίζετε, σε κάθε ένα turn, ποια tokens αξίζουν το window: τι να συμπιέσετε, τι να μετακινήσετε έξω σε μια σημείωση που μπορεί να ανακτήσει το agent, τι να δώσετε σε subagent με καθαρό window, και ποιοι ορισμοί εργαλείων αξίζουν τον μόνιμο φόρο τους. Το Κεφάλαιο 24 μετρά πού πηγαίνει πραγματικά το window — και η έκπληξη είναι ότι δεν είναι η συζήτηση.


Κάθε αριθμός σε αυτό το κεφάλαιο βγήκε από τους δύο servers που περιγράφονται παραπάνω, σε Node 22 πάνω από loopback interface: ένας scripted provider που μετρά token με το encoding o200k_base, και Qwen/Qwen2.5-0.5B-Instruct πίσω από endpoint του ίδιου σχήματος, greedy decoding, σε CPU. Τα κόστη υπολογίζονται από μετρημένα token counts στις τιμές που διάβασε το Κεφάλαιο 16 στις 6 Σεπτεμβρίου 2026 — $2.00 ανά εκατομμύριο input tokens και $12.00 ανά εκατομμύριο output — και κανένα request σε αυτό το κεφάλαιο δεν πήγε σε πληρωμένο endpoint. Οι απαντήσεις του τοπικού model είναι απαντήσεις μικρού model· διαβάστε τες ως evidence για το loop, που είναι ίδιο και στις δύο περιπτώσεις, και όχι ως benchmark του τι κάνουν τα σημερινά models.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. και Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Η εναλλαγή reasoning traces και actions που υλοποιεί το loop, και η πηγή της παρατήρησης ότι το acting επιτρέπει σε ένα model να "handle exceptions" — που είναι ακριβώς αυτό που μετρά ο πίνακας tool-error παραπάνω.

  2. Sumers, T. R., Yao, S., Narasimhan, K. και Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Η τυπική επεξεργασία αυτού που το loop παραπάνω κάνει άτυπα: modular memory components, ένας structured action space που εκτείνεται σε internal memory και external environments, και "a generalized decision-making process to choose actions". Διαβάστε το για το λεξιλόγιο που λείπει από τον κλαδικό όρο — ειδικά τον διαχωρισμό working, episodic, semantic και procedural memory, του οποίου η πρακτική σκιά είναι ο πίνακας three-store του Κεφαλαίου 24.

  3. ai (Vercel AI SDK) έκδοση 7.0.93, δημοσιευμένη στις 4 Σεπτεμβρίου 2026· τα type declarations διαβάστηκαν από cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts στις 7 Σεπτεμβρίου 2026. Το αρχείο 397 KB περιέχει μηδενικές εμφανίσεις του string harness. Η agent class είναι declare class ToolLoopAgent, εξαγόμενη τόσο ως ToolLoopAgent όσο και ως Experimental_Agent· το declare function isStepCount(stepCount: number) — εξαγόμενο ως stepCountIs — παρατίθεται κατά λέξη παραπάνω· το type StopCondition εμφανίζεται χωρίς τη δεύτερη type parameter του (RUNTIME_CONTEXT extends Context = Context), που είναι η μόνη παράλειψη στο απόσπασμα, όπως και το σχήμα του stopWhen?: Arrayable<StopCondition<...>> στα generateText και streamText. Το ίδιο αρχείο δηλώνει toolApproval, ToolApprovalStatus, prepareStep και repairToolCall, που σημαίνει ότι η reference implementation έχει φτάσει ανεξάρτητα σε approval gates, per-step preparation και error repair. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. και Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Η περίληψη αποκαλεί το artefact "evaluation framework" 2.294 προβλημάτων και δεν χρησιμοποιεί ποτέ τη λέξη "harness"· το README του ίδιου του project (github.com/SWE-bench/SWE-bench, αναγνώστηκε 7 Σεπτεμβρίου 2026) τη χρησιμοποιεί πέντε φορές, πάντα ως "evaluation harness", και το entry point είναι python -m swebench.harness.run_evaluation. Αυτή είναι η άλλη σημασία της λέξης: ένα scaffold που κρατά το agent ακίνητο και το βαθμολογεί, όχι το loop που το τρέχει.

  5. Anthropic, Building effective agents, 19 Δεκεμβρίου 2024, anthropic.com/engineering/building-effective-agents, αναγνώστηκε 7 Σεπτεμβρίου 2026. Το augmented model ως building block, το agent ως LLM "using tools based on environmental feedback in a loop", και η σύσταση για stopping conditions "such as a maximum number of iterations" ώστε να διατηρείται ο έλεγχος. Το Κεφάλαιο 22 παραθέτει τον ορισμό του πλήρως.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. και Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Το άλλο loop — ο serving scheduler που κάνει batching το request σας με requests αγνώστων και διαχειρίζεται το KV cache του Κεφαλαίου 13. Αξίζει να ξέρετε ότι υπάρχει ακριβώς επειδή δεν είναι δικό σας: το latency που πολλαπλασιάζει το harness σας ορίζεται μέσα του, και όσο κι αν δουλέψετε στο loop σας δεν το μετακινείτε.

  7. Μετρήσεις downloads του npm registry, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, ένα explicit window αντί για το rolling last-month, και release histories από registry.npmjs.org/<package>· και τα δύο ερωτήθηκαν στις 7 Σεπτεμβρίου 2026. Τα release counts είναι ο αριθμός εκδόσεων που δημοσιεύτηκαν στους δώδεκα μήνες έως εκείνη την ημερομηνία, συμπεριλαμβανομένων canary builds: ai 945 (τελευταία 7.0.93 στις 2026-09-04, με major versions 5, 6 και 7 να εμφανίζονται όλα μέσα στο window), langchain 132 (τελευταία 1.5.10 στις 2026-08-20), @openai/agents 83 (τελευταία 0.17.0 στις 2026-08-19, πρώτη δημοσίευση 2025-06-03). 2

  8. Το Claude Agent SDK (@anthropic-ai/claude-agent-sdk) είναι το Claude Code harness συσκευασμένο ως βιβλιοθήκη — agent loop, ενσωματωμένα εργαλεία file και shell, context management, sessions, hooks, permissions και subagents — τεκμηριωμένο στο code.claude.com/docs/en/agent-sdk. Είναι το πιο κοντινό πράγμα σε δημοσιευμένη καταγραφή κάθε μηχανισμού που αυτό το κεφάλαιο χτίζει με το χέρι, και αξίζει να το διαβάσετε δίπλα στη δική σας υλοποίηση για τα μέρη που ονομάζει και αυτό το κεφάλαιο μόνο υποδεικνύει.

Έτοιμοι να αφήσετε τη LIA να επιλέγει;

Δημιουργήστε με κάθε μοντέλο AI σε ένα σημείο — ξεκινήστε δωρεάν σήμερα.