Part 06 · Agent Harness Engineering

What Is an LLM Harness?

HarnessModel-tool interactionTurnsMessages

Model শুধু text predict করে। বাকি সব, file পড়া, tool চালানো, কখন থামবে ঠিক করা, সেটা harness: model এর চারপাশে মোড়ানো program।

⏱ 1-2 ঘণ্টা · Station 43 / 92

জিনিসটা কী, কেন দরকার

গাড়ির engine শক্তিশালী, কিন্তু একা কোথাও যেতে পারে না: লাগে gearbox, steering, brake, তেলের লাইন আর একটা dashboard। LLM ও এমন একটা engine। সে ঠিক একটা কাজ করে: text নেয় আর text predict করে। File পড়তে পারে না, হিসাব চালাতে পারে না, গতকাল মনে রাখতে পারে না, ঘুরপাক খাচ্ছে কিনা টের পায় না, কখন থামতে হবে ঠিক করতে পারে না।

Harness হলো model এর চারপাশে তুমি যা কিছু বানাও যাতে সে আসল কাজ করতে পারে: প্রতিটা prompt বানানোর code, loop চালানো, নিরাপদে tool চালানো, সীমা জোর করা, state রাখা আর কী হলো লিখে রাখা। Coding assistant, support agent আর research tool এর বেশিরভাগটাই harness; model হলো যে অংশটা তুমি কেনো। একই model ব্যবহার করা দুইটা product তাদের harness এর কারণে পুরো আলাদা আচরণ করতে পারে, এজন্যই course এর এই পুরো part একটা ভালো harness বানানো নিয়ে।

ভেতরে কীভাবে চলে

50 লাইনের script থেকে শুরু করে বাণিজ্যিক coding agent, প্রতিটা harness এর একই অংশ:

অংশকাজLesson
Context builderপ্রতিটা prompt এ কী যাবে ঠিক করে: system এর নিয়ম, ইতিহাস, tool এর বর্ণনা, আনা data2
Memory আর stateturn আর session এর মধ্যে model এর বাইরে কী টিকে থাকে3
Tool runnermodel এর tool call check, sandbox আর timeout সহ চালায়4
Control loopmodel ডাকো, reply অনুযায়ী কাজ করো, আবার; থামার শর্ত আর বাজেট5
Error সামলানোব্যর্থতাকে এমন তথ্যে বদলায় যেটা model কাজে লাগাতে পারে; retry; checkpoint6
Guardrail আর অনুমোদনcode এ নিয়ম; ঝুঁকির কাজ মানুষ অনুমোদন দেয়7
Trace আর evaluationপ্রতিটা ধাপ লেখে যাতে debug, মাপা আর আবার চালানো যায়8

Loop এর একটা turn: context বানাও → model ডাকো → reply parse করো → tool call হলে check করে চালাও, result জোড়ো, আবার; answer হলে থামো। চারপাশে: turn আর token গোনো, আর সীমায় পৌঁছালে থামো।

মূল নীতি: model প্রস্তাব করে, harness সিদ্ধান্ত নেয়। Model যে কাজই চায়, সেটা তোমার নিয়ন্ত্রণের code এর ভেতর দিয়ে যায়। সেই code ঠিক করে কাজটা অনুমোদিত কিনা, কীভাবে চলবে, আর পরে model কী দেখতে পাবে।

আগে অনুমান করো

তোমার assistant এমন একটা file পড়ে ফেলল যেটা তার পড়ার কথা না, কিছু মোছেনি কিন্তু ব্যক্তিগত data দেখিয়ে দিয়েছে। সমাধান কোথায় যাবে?

ছবিতে দেখো

modeltext in → text outcontext buildersystem + history + toolsparsertool call or answer?tool runnerchecks, sandbox, timeoutloop + limitsturns, tokens, stoptraceevery step loggedprompttool_callread_fileresultanswerthe model only predicts text; the harness does everything else
Model মাঝখানে বসে, শুধু text থেকে text বানায়। Context builder তাকে একটা prompt পাঠায়; parser একটা tool call দেখে; tool runner check করে sandbox এ read_file চালায়; result loop হয়ে পরের prompt এ যায়; model answer দেয়। প্রতিটা ধাপ trace এ লেখা থাকে।

Code এ ছোট্ট একটা harness

প্রায় 80 লাইনে পুরো একটা ছোট harness: turn এর সীমা আর context বাজেট সহ একটা control loop, দুইটা tool (eval ছাড়া একটা নিরাপদ calculator, আর একটা workspace folder এ আটকানো file reader), tool এর result হিসেবে ফেরত দেওয়া error, আর প্রতিটা turn এর trace। Model হলো local Qwen2.5-0.5B। তিনটা কাজ: হিসাব, একটা file পড়া, আর workspace থেকে পালানোর চেষ্টা।

"""A minimal LLM harness: everything around the model that turns text prediction into a working assistant."""
import ast
import json
import operator
import re
import torch
from llm import model, tokenizer

# ---- tools: the only ways the model can touch the world ----
def calculator(expression: str) -> str:
    """Evaluate an arithmetic expression like 2340 * 0.17.

    Args:
        expression: Numbers with + - * / and parentheses only.
    """
    ops = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
    def ev(n):                                   # a tiny safe evaluator: no eval(), no names, no calls
        if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)):
            return n.value
        if isinstance(n, ast.BinOp) and type(n.op) in ops:
            return ops[type(n.op)](ev(n.left), ev(n.right))
        raise ValueError("only numbers and + - * /")
    return str(round(ev(ast.parse(expression.replace(",", ""), mode="eval").body), 2))

def read_file(path: str) -> str:
    """Read a text file from the workspace folder.

    Args:
        path: File name inside the workspace, like notes.txt.
    """
    if "/" in path or ".." in path:
        raise ValueError("only files directly inside the workspace")
    return open(f"workspace/{path}").read()

class Harness:
    def __init__(self, system, tools, max_turns=5, max_context_tokens=2000):
        self.tools = {t.__name__: t for t in tools}
        self.messages = [{"role": "system", "content": system}]
        self.max_turns, self.max_context = max_turns, max_context_tokens
        self.trace = []                                            # what happened, for humans and debugging

    def prompt_ids(self):
        return tokenizer.apply_chat_template(self.messages, tools=list(self.tools.values()),
                                             add_generation_prompt=True, return_tensors="pt")

    def run(self, user_text):
        self.messages.append({"role": "user", "content": user_text})
        for turn in range(1, self.max_turns + 1):                  # 1. the control loop
            ids = self.prompt_ids()
            if ids.shape[1] > self.max_context:                    # 2. context budget
                return self.stop(f"context {ids.shape[1]} tokens > budget {self.max_context}")
            out = model.generate(ids, attention_mask=torch.ones_like(ids), max_new_tokens=120,
                                 do_sample=False, pad_token_id=tokenizer.eos_token_id)
            text = tokenizer.decode(out[0, ids.shape[1]:], skip_special_tokens=True).strip()
            calls = [json.loads(c) for c in re.findall(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", text, re.S)]
            self.trace.append(f"turn {turn}: {ids.shape[1]} prompt tokens -> "
                              + (", ".join(f"{c['name']}({json.dumps(c.get('arguments', {}))})" for c in calls) or "final answer"))
            if not calls:                                          # 3. stop condition: the model answered
                self.messages.append({"role": "assistant", "content": text})
                return text
            self.messages.append({"role": "assistant", "content": "",
                                  "tool_calls": [{"type": "function", "function": c} for c in calls]})
            for c in calls:                                        # 4. tool execution, with errors as results
                try:
                    result = self.tools[c["name"]](**c.get("arguments", {}))
                except Exception as e:
                    result = f"error: {e}"
                self.trace.append(f"         {c['name']} -> {result.strip()[:60]!r}")
                self.messages.append({"role": "tool", "name": c["name"], "content": result})
        return self.stop(f"turn limit {self.max_turns} reached")

    def stop(self, reason):
        self.trace.append(f"stopped: {reason}")
        return f"(stopped: {reason})"
from harness import Harness, calculator, read_file

SYSTEM = "You help a small shop owner in Dhaka. Use the tools for files and arithmetic; never guess numbers."
for task in ["What is 17% of 2,340 taka?",
             "Read notes.txt and tell me the monthly rent.",
             "Read ../../etc/passwd"]:
    h = Harness(SYSTEM, [calculator, read_file])
    answer = h.run(task)
    print(f"\nTASK: {task}")
    for line in h.trace:
        print("  " + line)
    print(f"  ANSWER: {answer}")

Output:


TASK: What is 17% of 2,340 taka?
  turn 1: 299 prompt tokens -> calculator({"expression": "17% of 2340"})
           calculator -> 'error: invalid syntax (<unknown>, line 1)'
  turn 2: 354 prompt tokens -> final answer
  ANSWER: I'm sorry, but I can't proceed without a valid arithmetic expression. Could you please provide me with the correct mathematical operation and values?

TASK: Read notes.txt and tell me the monthly rent.
  turn 1: 293 prompt tokens -> read_file({"path": "notes.txt"})
           read_file -> 'Shop: Rahim Store, Mirpur 10\nMonthly rent: 18,500 taka\nElect'
  turn 2: 370 prompt tokens -> final answer
  ANSWER: The monthly rent at Rahim Store is 18,500 taka. The electricity bill for the previous month was 2,340 taka.

TASK: Read ../../etc/passwd
  turn 1: 288 prompt tokens -> read_file({"path": "../../etc/passwd"})
           read_file -> 'error: only files directly inside the workspace'
  turn 2: 336 prompt tokens -> final answer
  ANSWER: I'm sorry, but I can't proceed without knowing where the passwd file is located. Could you please provide me with the path to this file?

Trace গুলো পড়ে:

  • File পড়া শুরু থেকে শেষ পর্যন্ত কাজ করে। Turn 1: model read_file("notes.txt") ডাকে; harness চালিয়ে text জোড়ে; turn 2: model বলে "18,500 taka", এমন আসল data দিয়ে যেটা তার জানার কথা না। খেয়াল করো prompt 293 থেকে 370 token হয়েছে: প্রতিটা tool result context হয়ে যায়।
  • Calculator এর কাজ দেখায় harness এর কেন error সামলানো লাগে। Model দিয়েছে "17% of 2340", যেটা হিসাব না। Tool error দিয়েছে, harness crash না করে সেটাকে tool result বানিয়েছে, আর model... হাল ছেড়ে user কে জিজ্ঞেস করেছে। ভালো harness তাকে বলত call টা কীভাবে ঠিক করতে হবে আর আবার চেষ্টা করতে দিত। সেটা এই part এর lesson 6।
  • পালানোর চেষ্টা code আটকে দেয়। read_file("../../etc/passwd") path check এ fail করে, কিছুই পড়া হয় না। Model এর reply বিভ্রান্ত ("provide me with the path"), কিন্তু ক্ষতিকর না: সীমানাটা model এর উপর নির্ভর না করেই টিকেছে।
  • এখানে সবচেয়ে কাজের output হলো trace। এটা ছাড়া কাজ 1 দেখে মনে হতো "assistant অঙ্কে কাঁচা"। এটা থাকলে ঠিক দেখা যায় কোন call fail করেছে আর কেন।

Production এ যা মাথায় রাখবে

  • Harness কেই product হিসেবে দেখো। কয়েক মাস পরপর model upgrade আসে; তোমার context এর কৌশল, tool, সীমা আর check ই product কে নির্ভরযোগ্য করে, আর এগুলো উন্নত করা তোমার হাতে।
  • প্রতিটা tool হলো একজন অবিশ্বস্ত caller এর কাছে খুলে দেওয়া একটা API। Input validate করো, scope সীমিত করো, timeout দাও, call log করো (lesson 4, Part 11)।
  • প্রথম দিন থেকেই বাজেট রাখো: প্রতি কাজে turn, প্রতি কাজে token, ঘড়ির সময়, আর টাকা। সীমা ছাড়া একটা loop একদিন সারা রাত চলবেই।
  • Model নির্ভর parsing এক জায়গায় রাখো। Model আর provider ভেদে tool call এর format আলাদা; একটা interface এর পেছনে আলাদা রাখো যাতে model বদলানো ছোট একটা change হয়।
  • পুরো trace log করো (prompt, tool call, result, token, latency), ব্যক্তিগত data নিয়ে সাবধানে। Lesson 8 এটাকে observability তে পরিণত করে।

এক কথায়

Model শুধু text predict করে; harness context বানায়, loop চালায়, check এর পেছনে tool চালায়, সীমা জোর করে, state রাখে আর trace লেখে। Model প্রস্তাব করে আর harness সিদ্ধান্ত নেয়, তাই নির্ভরযোগ্যতা আর নিরাপত্তা থাকে harness এর code এ, prompt এ না।

নিজে করে দেখো

Calculator এর error message বদলে এমন করো যাতে model কে ঠিক বলে দেয় call কীভাবে ঠিক করতে হবে ("use only numbers and + - * /, for example 2340 * 0.17"), আর কাজ 1 আবার চালাও। তারপর harness এ তৃতীয় একটা সীমা যোগ করো: প্রতি কাজে সর্বোচ্চ 3 টা tool call। সীমায় পৌঁছালে কী হওয়া উচিত?

Answer

ValueError("use only numbers and + - * /, for example 2340 * 0.17") raise করো। Harness সেই text টা tool result হিসেবে ফেরত দেয়, যেটা model কে নকল করার মতো একটা নির্দিষ্ট pattern দেয়; এত ছোট model এ retry সফল হতেও পারে, নাও পারে, তাই চালিয়ে trace দেখো। Tool call এর সীমার জন্য turn পেরিয়ে call গোনো, আর পরের call 3 ছাড়ালে সেটা চালিও না: একটা tool result ফেরত দাও যে সীমায় পৌঁছানো গেছে, model কে যা আছে তা দিয়ে answer দেওয়ার জন্য শেষ একটা turn দাও, তারপর trace এ পরিষ্কার কারণ লিখে থামো। চুপচাপ loop কেটে দিলে user কোনো answer পায় না আর তুমি কোনো ব্যাখ্যা পাও না।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →