Skip to content

Agent Loop Core Implementation Principles

1. Architecture Overview

The core of harness9 is a standard ReAct loop engine: each Turn executes one LLM call and decides whether to execute a tool or end the task based on the response. The engine orchestrates three core abstractions working together:

┌──────────────────────────────────────────────────────────────────────┐
│                         AgentEngine                                   │
│                    (Core Orchestrator / ReAct Loop)                   │
│                                                                      │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │                Single-phase flow per Turn                       │  │
│  │                                                                │  │
│  │  LLM Call                                                       │  │
│  │  ┌───────────────┐  Generate(tools=all)  ┌───────────────┐    │  │
│  │  │  Context       │ ─────────────────── ► │  LLMProvider   │    │  │
│  │  │  History       │ ◄── Text + ToolCalls ─│  (Reasoning &  │    │  │
│  │  └───────┬───────┘                       │   Acting)      │    │  │
│  │          │                                └───────────────┘    │  │
│  │          │ Injected into contextHistory                        │  │
│  │          │                                                      │  │
│  │          │ ToolCalls                                            │  │
│  │          ▼                                                      │  │
│  │  ┌───────────────┐  Execute()  ┌───────────────┐              │  │
│  │  │  Observation   │ ◄────────── │  Registry      │              │  │
│  │  │  (Tool Result) │             │  (Tool Exec    │              │  │
│  │  └───────────────┘             │   Layer)       │              │  │
│  │                                └───────────────┘              │  │
│  └────────────────────────────────────────────────────────────────┘  │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘
ComponentCode LocationResponsibility
schemainternal/schema/message.goDefines core data types shared across components
schema.StreamChunkinternal/schema/stream.goProvider-layer streaming delta data type
LLMProviderinternal/provider/interface.goAbstracts the LLM communication layer, encapsulating API differences (blocking + streaming)
OpenAIProviderinternal/provider/openai.goOpenAI-compatible API adapter (OpenAI / OpenRouter / Azure)
AnthropicProviderinternal/provider/anthropic.goAnthropic-compatible API adapter (Anthropic / OpenRouter)
Registryinternal/tools/registry.goDecouples tool discovery from execution
AgentEngine.Runinternal/engine/agent_loop.goBlocking ReAct main loop
AgentEngine.RunStreaminternal/engine/stream.goStreaming ReAct main loop, token-by-token output
engine.Eventinternal/engine/stream.goClient-facing streaming event type from the engine
envinternal/env/env.goEnvironment variable configuration loading based on .env files

2. ReAct Design Philosophy

ReAct (Reasoning + Acting) is the standard Agent loop pattern adopted by harness9. In each Turn, the LLM receives the current conversation context (including historical tool results) and outputs both reasoning text and a tool call request (or a final reply) at the same time.

Turn N:
  LLM(contextHistory, tools) → Reasoning text + ToolCalls (or plain-text final reply)
  → If ToolCalls present: execute concurrently → inject result as Observation into context → Turn N+1
  → If no ToolCalls: task complete, exit loop

The emitter abstraction decouples the loop kernel (runLoop) from output-side behavior, allowing the blocking mode (Run) and the streaming mode (RunStream) to share the same loop logic:

emitter MethodBlocking Mode BehaviorStreaming Mode Behavior
generateCalls Generate, prints text to stdoutCalls GenerateStream, sends text deltas as EventActionDelta
toolStartWrites structured logWrites log + sends EventToolStart
toolDoneWrites structured logWrites log + sends EventToolResult

3. Data Model (internal/schema)

3.1 Message Role System

Role (string)
├── "system"     → System prompt: defines the Agent's identity, constraints, and behavioral boundaries
├── "user"       → User input & tool execution results (Observation)
└── "assistant"  → Model output: reasoning text + tool call requests

Each Turn produces one assistant message (containing reasoning text and/or ToolCalls), along with zero or more user messages (one per tool result).

3.2 Core Type Relationships

┌──────────────────────────────────────────────────┐
│  Message                                         │
│  ├── Role        Role        Message author role  │
│  ├── Content     string      Plain-text content   │
│  ├── ToolCalls   []ToolCall  Tool call requests from the model │
│  └── ToolCallID  string      Links to original ToolCall's ID  │
│                                                  │
│  ToolCall                 ToolResult              │
│  ├── ID         string     ├── ToolCallID  string │
│  ├── Name       string     ├── Output      string │
│  └── Arguments  RawMessage └── IsError      bool  │
│                                                  │
│  ToolDefinition                                  │
│  ├── Name        string   Unique tool identifier  │
│  ├── Description string   Purpose description      │
│  └── InputSchema interface{} Parameter JSON Schema │
└──────────────────────────────────────────────────┘

Key design decisions:

  • ToolCall.Arguments uses json.RawMessage: deferred deserialization, delegating argument parsing responsibility to the concrete tool implementation.
  • ToolDefinition.InputSchema uses interface{}: different LLM SDKs require different tool parameter formats (OpenAI needs shared.FunctionParameters, Anthropic needs map[string]any); each Provider handles the type conversion internally, avoiding extra JSON round-trip serialization overhead.
  • ToolCallID correlation mechanism: tool execution results (Observation) are linked to the original ToolCall via ToolCallID.
  • ToolResult.IsError self-healing marker: when a tool execution fails, the engine exposes the error to the LLM, allowing it to attempt to correct the arguments and retry (Self-Healing).

3.3 Streaming Data Types

Provider Layer — schema.StreamChunk (internal/schema/stream.go)

The Provider returns a <-chan StreamChunk via the GenerateStream method; each chunk represents one incremental output from the LLM. Streaming accumulation of tool call arguments is done internally within the Provider by toolCallAccumulator and is not exposed as intermediate state via StreamChunk — the complete Message.ToolCalls in StreamChunkDone is already the final accumulated result:

StreamChunk
├── Type     StreamChunkType  Chunk type identifier
├── Delta    string           Text delta (valid for text_delta / thinking_delta)
├── Message  *Message         Complete response (valid when done, contains ToolCalls)
├── Usage    *Usage           Token usage (filled by Provider when done)
└── Error    string           Error message (valid when error)

Chunk type lifecycle:

text_delta ──────────────────────────────────────┐   (multiple, token by token)

thinking_delta ──────────────────────────────────┤   (multiple, reasoning content, optional)


                                               done  (stream ended, carries complete Message + Usage)
StreamChunkTypeMeaningCarried Data
text_deltaText delta, token by tokenDelta
thinking_deltaReasoning delta (extended thinking / reasoning_content)Delta
doneStream endedMessage (complete response, contains ToolCalls), Usage
errorError occurredError

Tool call accumulation note: Internally, the Provider uses toolCallAccumulators (internal/provider/tool_call_accumulator.go) to concatenate the JSON fragments returned by the SDK's streaming interface into complete tool arguments, ultimately placing them all into StreamChunkDone.Message.ToolCalls, so upstream layers do not need to be aware of the intermediate state.

Engine Layer — engine.Event (internal/engine/stream.go)

The engine returns a <-chan Event via the RunStream method, converting the Provider's low-level StreamChunk into client-facing semantic events:

Event
├── Type EventType  Event type
├── Turn int        Current Turn number
└── Data any        Event payload (type varies with Type)
EventTypeMeaningData Type
action_deltaText delta output by the LLM (token by token)string
thinking_deltaReasoning content delta (extended thinking / reasoning)string
tool_startTool execution startsschema.ToolCall
tool_resultTool execution completedToolResultData (contains Result schema.ToolResult and Duration time.Duration)
token_updateEmitted before each LLM call, reporting token estimateTokenUpdateData
compactionContext underwent effective compaction (token reduction > 5%)CompactionData
approval_requiredTool execution requires human approvalApprovalRequest
doneLoop ended normallynil
errorError occurredstring

Example event flow:

Turn 1:
  token_update        ← Estimate before LLM call
  thinking_delta × N  ← Reasoning content (if the model supports extended thinking)
  action_delta × N    ← LLM token-by-token output
  token_update        ← Actual token usage (updated after LLM returns)
  approval_required   ← Waiting for human approval on a dangerous tool (optional)
  tool_start          ← Tool execution starts
  tool_result         ← Tool execution completed
Turn 2:
  token_update        ← Next round's estimate
  action_delta × N    ← Final reply (no tool call)
  done                ← Loop ended

4. Agent Loop Cycle Flow

                     ┌─────────────────────┐
                     │   Initialize context  │
                     │   System(with WorkDir)│
                     │   + User              │
                     └──────────┬──────────┘

                ┌───────────────▼───────────────┐
                │   Turn count ++                 │
                │   Check MaxTurns / ctx.Done()  │
                └───────────────┬───────────────┘

                   ┌────────────▼────────────┐
                   │  LLM call                │
                   │  Generate(availableTools)│
                   │  → Inject into contextHistory│
                   └────────────┬────────────┘

                       ┌────────▼────────┐    Has ToolCalls
                       │  Termination check│──────────────────┐
                       │  ToolCalls == 0? │                   │
                       └────────┬────────┘                   │
                                │ No ToolCalls               │
                       ┌────────▼────────┐    ┌──────────────┴───────────┐
                       │  Task complete    │    │  ToolCall phase (concurrent)│
                       │  Exit loop        │    │  Semaphore limits concurrency│
                       └─────────────────┘    │  Each tool has independent timeout│
                                              └────────────┬─────────────┘

                                             ┌─────────────▼────────────┐
                                             │  Observation phase       │
                                             │  Append tool results to context│
                                             └────────────┬─────────────┘

                                             ┌─────────────▼────────────┐
                                             │  Back to Turn count ++   │
                                             └──────────────────────────┘

4.1 Initialization Phase

When the engine starts, it constructs the initial conversation context via loadHistoryWith. If a Session is injected, historical messages are restored from persistent storage; otherwise it contains only the system prompt and the current user input:

go
// loadHistoryWith restores historical messages from the Session, injects the system prompt,
// and appends the user input.
// startLen marks the starting position of new messages (existing history + system prompt are not persisted),
// used by saveHistoryWith to save only msgs[startLen:].
func (e *AgentEngine) loadHistoryWith(ctx context.Context, userPrompt string, sess memory.Session) ([]schema.Message, int) {
    var history []schema.Message
    if sess != nil {
        msgs, err := sess.GetMessages(ctx, 0) // 0 = return all history
        if err == nil {
            history = msgs
        }
    }
    // The system prompt is injected at the beginning of the history messages (if not already present),
    // rebuilt on every call, not persisted to the DB.
    if len(history) == 0 || history[0].Role != schema.RoleSystem {
        history = append([]schema.Message{{Role: schema.RoleSystem, Content: e.buildSystemPrompt()}}, history...)
    }
    startLen := len(history) // New messages start here; the system prompt is not counted in the persistence range
    history = append(history, schema.Message{Role: schema.RoleUser, Content: userPrompt})
    return history, startLen
}

WorkDir is injected into the system prompt so the LLM knows its working directory. The system prompt itself is not persisted (it is rebuilt and prepended to the front of the history messages on every startup, avoiding duplicate insertion); startLen marks the starting position of new messages, used by saveHistoryWith to save only msgs[startLen:].

4.2 LLM Call Phase

Each Turn executes one LLM call, carrying the complete tool list:

go
availableTools := e.registry.GetAvailableTools()
responseMsg, err := em.generate(ctx, turnCount, contextHistory, availableTools)
contextHistory = append(contextHistory, *responseMsg)

4.3 Termination Condition Detection

The engine implements a triple safety guarantee:

go
// 1. MaxTurns limit: prevents infinite loops
if e.maxTurns > 0 && turnCount > e.maxTurns {
    return fmt.Errorf("maximum turn count reached (%d), loop terminated", e.maxTurns)
}

// 2. Context cancellation: supports timeout and manual interruption
select {
case <-ctx.Done():
    return fmt.Errorf("context cancelled: %w", ctx.Err())
default:
}

// 3. Natural termination: the model no longer requests tool calls
if len(responseMsg.ToolCalls) == 0 {
    break
}

4.4 ToolCall Phase — Concurrent Execution (with Independent Timeouts)

When the model requests multiple tool calls, the engine uses goroutine + sync.WaitGroup for concurrent execution. An optional semaphore (maxConcurrentTools) controls maximum concurrency, and each tool has an independent timeout control:

go
go func(idx int, tc schema.ToolCall) {
    defer wg.Done()

    if sem != nil {
        sem <- struct{}{}
        defer func() { <-sem }()
    }

    // Independent timeout: a single tool's timeout does not affect other tools
    toolCtx := ctx
    if e.toolTimeout > 0 {
        toolCtx, cancel = context.WithTimeout(ctx, e.toolTimeout)
        defer cancel()
    }

    results[idx] = e.registry.Execute(toolCtx, tc)
}(i, toolCall)

Key concurrency safety design points:

IssueSolution
Multiple goroutines writing to the same result setPre-allocated slice, each goroutine writes to its own position by index idx
Result order consistencyIndex corresponds one-to-one with the original ToolCalls order
Single tool timeoutcontext.WithTimeout creates an independent child context for each tool
Closure variable captureidx, tc passed explicitly, avoiding data races
Concurrency controlBuffered channel semaphore, 0 = unlimited

4.5 Observation Phase

After tool execution completes, the results are appended to the context in their original order:

go
for i, toolCall := range responseMsg.ToolCalls {
    contextHistory = append(contextHistory, schema.Message{
        Role:       schema.RoleUser,        // Observation is passed back with the user role
        Content:    results[i].Output,
        ToolCallID: toolCall.ID,             // Links to the original request
    })
}

4.6 Streaming Architecture (RunStream)

RunStream is the streaming counterpart of Run, sharing the same runLoop main loop logic, outputting event by event via a Go channel. Core data flow:

┌─────────────┐  GenerateStream()  ┌──────────────────┐
│  LLMProvider │ ───────────────── │  chan StreamChunk  │
│  (OpenAI /   │                   │  (token-by-token   │
│   Anthropic) │                   │   delta)           │
└─────────────┘                   └────────┬─────────┘


                                   ┌──────────────────┐
                                   │  streamGenerate() │
                                   │  Reads StreamChunk│
                                   │  Forwards as Event│
                                   └────────┬─────────┘


┌─────────────┐  Execute()         ┌──────────────────┐
│  Registry    │ ─────────────────  │    chan Event     │
│  (Tool Exec) │                    │  (client-facing)  │
└─────────────┘                    └────────┬─────────┘


                                   ┌──────────────────┐
                                   │  Client consumer  │
                                   │   (TUI / CLI /    │
                                   │    SSE handler)   │
                                   └──────────────────┘

The streamGenerate method replaces the direct call to Generate used in blocking mode. It calls GenerateStream, reads from the StreamChunk channel, and forwards it as semantic Events:

go
func (e *AgentEngine) streamGenerate(ctx context.Context, ch chan<- Event,
    turn int, history []schema.Message, tools []schema.ToolDefinition) (*schema.Message, error) {

    stream, err := e.provider.GenerateStream(ctx, history, tools)
    for chunk := range stream {
        switch chunk.Type {
        case schema.StreamChunkTextDelta:
            sendEvent(ctx, ch, Event{Type: EventActionDelta, Turn: turn, Data: chunk.Delta})
        case schema.StreamChunkDone:
            msg = chunk.Message
        }
    }
    return msg, nil
}

Context cancellation awareness: all channel sends go through select listening on ctx.Done(), ensuring that sends never block on cancellation:

go
func sendEvent(ctx context.Context, ch chan<- Event, evt Event) bool {
    select {
    case <-ctx.Done():
        return false
    case ch <- evt:
        return true
    }
}

5. Interface Abstraction and Decoupling Design

5.1 LLMProvider Interface

go
type LLMProvider interface {
    // Blocking call: returns the complete response Message and actual token usage (Usage may be nil)
    Generate(ctx context.Context, messages []schema.Message,
             availableTools []schema.ToolDefinition) (*schema.Message, *schema.Usage, error)

    // Streaming call: returns incremental chunks via channel; the last valid chunk type is StreamChunkDone
    GenerateStream(ctx context.Context, messages []schema.Message,
                   availableTools []schema.ToolDefinition) (<-chan schema.StreamChunk, error)
}

Design philosophy:

  • The engine only depends on the interface; switching models only requires replacing the Provider implementation
  • Dual-mode coexistence: Generate is used for blocking scenarios, GenerateStream for streaming scenarios
  • The channel returned by GenerateStream closes automatically once the stream ends; the last valid chunk's Type is StreamChunkDone

5.2 Concrete Implementations

Both Providers adopt a unified message conversion layer architecture, where Generate and GenerateStream share the same conversion logic:

                    ┌──────────────────┐
                    │  convertMessages  │ ← schema.Message → native SDK message
                    │  convertTools     │ ← schema.ToolDefinition → native SDK tool
                    └───────┬──────────┘

               ┌────────────┼─────────────┐
               ▼                           ▼
        Generate()                 GenerateStream()
        SDK.New()                  SDK.NewStreaming()
        → *Message                 → chan StreamChunk

OpenAIProvider (internal/provider/openai.go)

An OpenAI-compatible implementation, supporting any backend that follows the OpenAI Chat Completion API spec:

Environment VariableDescription
OPENAI_API_KEYAPI authentication key (required)
OPENAI_BASE_URLAPI endpoint base URL, e.g. https://api.openai.com/v1 (required)
go
p, err := provider.NewOpenAIProvider("gpt-4o")

Message conversion rules:

schema TypeOpenAI SDK Type
RoleSystemopenai.SystemMessage
RoleUser (with ToolCallID)openai.ToolMessage(content, toolCallID)
RoleUser (without ToolCallID)openai.UserMessage(content)
RoleAssistantChatCompletionAssistantMessageParam (contains ToolCalls)
ToolDefinitionopenai.ChatCompletionFunctionTool

The conversion of InputSchema's interface{}shared.FunctionParameters is handled by the convertToFunctionParameters function: it first attempts a direct type assertion, falling back to a JSON round trip on failure.

Streaming implementation: GenerateStream uses client.Chat.Completions.NewStreaming(), returning *ssestream.Stream[ChatCompletionChunk]. Internally, openaiToolCallAccumulator accumulates tool call arguments.

AnthropicProvider (internal/provider/anthropic.go)

An Anthropic-compatible implementation, supporting both the Anthropic official endpoint and compatible endpoints like OpenRouter:

Environment VariableDescription
ANTHROPIC_API_KEYAPI authentication key (required)
ANTHROPIC_BASE_URLAPI endpoint base URL, e.g. https://api.anthropic.com (required)
go
p, err := provider.NewAnthropicProvider("claude-sonnet-4-20250514", 4096)
//                                                        model     maxTokens

Anthropic API special handling:

DifferenceHandling
System prompt is not in the messages arrayExtracted from the RoleSystem message, set as params.System
ToolUseBlock's Input typejson.Unmarshal parses Arguments into map[string]interface{}
required field typeextractSchemaFields safely handles []interface{}[]string conversion
MaxTokens must be explicitly specifiedPassed via constructor argument, defaults to 4096

Streaming implementation: GenerateStream uses client.Messages.NewStreaming(), returning *ssestream.Stream[MessageStreamEventUnion]. Event type mapping:

Anthropic EventHandling
content_block_start (type=tool_use)StreamChunkToolCallStart, records ID/Name
content_block_delta (type=text_delta)StreamChunkTextDelta
content_block_delta (type=input_json_delta)StreamChunkToolCallDelta, accumulates partial JSON

5.3 Environment Configuration (internal/env)

The env package provides a zero-dependency .env file loader, called at program startup:

go
env.Load(filepath.Join(workDir, ".env"))
FeatureDescription
System environment variables take priorityExisting environment variables are not overridden by the .env file
Silently skips missing fileReturns nil when no .env file exists, does not block startup
Supports quoted valuesAutomatically strips matched pairs of double or single quotes
Comments and blank linesLines starting with # and blank lines are skipped

5.4 Registry Interface

go
type Registry interface {
    Register(tool BaseTool) error
    GetAvailableTools() []schema.ToolDefinition
    Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult
}

5.5 Dependency Injection + Functional Options

go
eng := engine.NewAgentEngine(p, r, workDir,
    engine.WithMaxTurns(100),
    engine.WithToolTimeout(30 * time.Second),
    engine.WithMaxConcurrentTools(4),
    engine.WithSession(sess),
    engine.WithCompactor(&memory.SlidingWindowCompactor{MaxMessages: 100}),
)
OptionTypeDefaultDescription
WithMaxTurns(n)int50Maximum number of Turns per Run, 0 = unlimited
WithToolTimeout(d)time.Duration60sTimeout for a single tool execution, 0 = use the original context
WithMaxConcurrentTools(n)int0Maximum concurrent tools within the same Turn, 0 = unlimited
WithSession(s)memory.SessionnilInjects session storage, enabling persistence of historical messages
WithCompactor(c)memory.CompactornilInjects a context compactor, controlling context window size
WithContextWindow(n)int0Model's context window (tokens), used for TUI token usage display
WithPromptBuilder(pb)PromptBuildernilCustom system prompt builder; when nil, the built-in default text is used
WithPlanMode(mode)planning.PlanModeDefaultInitial execution mode; can be updated at runtime via SetPlanMode
WithTodoStore(s)*planning.TodoStorenilBinds a todo list, enabling cross-session todo persistence
WithEngineObserver(o)EngineObservernoopObserverInjects a lifecycle observer (OTEL Tracing, etc.); degrades to noop if nil
WithMemoryNudge(n, text)int, string0, ""Injects a Long-Term Memory hint into the defensive copy every n turns, 0 disables it

At runtime, eng.SetSession(sess) can switch sessions and eng.SetPlanMode(mode) can switch execution mode (both are concurrency-safe, using sync.RWMutex internally, but have no effect on a runLoop currently in progress).

Dual-mode invocation:

go
// Blocking: synchronously waits for the complete result
err := eng.Run(ctx, prompt)

// Streaming: returns event by event via channel
stream, err := eng.RunStream(ctx, prompt)
for evt := range stream {
    switch evt.Type {
    case engine.EventActionDelta:
        fmt.Print(evt.Data.(string))  // Token-by-token output
    case engine.EventDone:
        // Loop ended
    }
}

Both modes share the same AgentEngine instance and configuration, and can be freely chosen at runtime.

6. Logging and Observability

6.1 EngineObserver Interface

EngineObserver is the sole extension interface the engine provides for the observability layer. runLoop calls it back at 4 lifecycle points:

go
type EngineObserver interface {
    OnInteractionStart(ctx, sessionID, prompt) context.Context  // runLoop entry point
    OnInteractionEnd(ctx, turns, err)                           // runLoop exit (guaranteed via defer)
    OnTurnStart(ctx, turn) context.Context                      // Start of each Turn
    OnTurnEnd(ctx, turn, hasToolCalls)                          // End of each Turn
}

All OnXxxStart methods return an enhanced ctx (which may carry an OTEL Span), for the LLM call and tool execution to inherit the parent chain from. When not injected, it automatically degrades to the zero-overhead noopObserver.

Notes for custom Observers: when implementing OnInteractionStart / OnTurnStart, in addition to storing the Span into a custom key via context.WithValue (for OnInteractionEnd / OnTurnEnd to retrieve), you must also write the Span into the OTEL standard slot via trace.ContextWithSpan(ctx, span) — otherwise the downstream tracer.Start(ctx, ...) cannot find the parent node, causing every Span to independently become a root node:

go
func (o *MyObserver) OnInteractionStart(ctx context.Context, sessionID, prompt string) context.Context {
    ctx, span := o.tracer.Start(ctx, "my.interaction")
    // ① Write into the OTEL standard slot — downstream tracer.Start auto-nests
    ctx = trace.ContextWithSpan(ctx, span)
    // ② Write into a custom key — for OnInteractionEnd to retrieve
    return context.WithValue(ctx, mySpanKey{}, span)
}

6.2 Structured Logging

The engine uses a structured logging format, with the [engine] prefix for blocking mode and the [engine-stream] prefix for streaming mode:

Blocking mode log example:

[engine] started | workdir=/Users/zsa/project maxTurns=50 toolTimeout=1m0s maxConcurrent=0
[engine] ======== Turn 1 ======== | history=2  tools=3
[engine] tool started | name=bash id=call_123
[engine] tool completed | name=bash bytes=45
[engine] Turn 1 | Observation injection complete | history=4 | llm=1.2s tools=0.3s turn=1.5s
[engine] ======== Turn 2 ======== | history=4  tools=3
[engine] Turn 2 | task complete | llm=0.8s total=2.3s
[engine] loop ended | totalTurns=2 | total_time=2.3s

Log layering:

LayerPrefixContentOutput Method
Engine internal (blocking)[engine]Turn counting, tool statuslog.Printf (stderr)
Engine internal (streaming)[engine-stream]Same as abovelog.Printf (stderr)
Model output (blocking)[assistant]Text content produced by the LLMfmt.Printf (stdout)
Model output (streaming)No prefixHanded to the client via Event channelControlled by the consumer

7. Complete Data Flow Diagram

Taking a two-turn conversation as an example:

Turn 1:
  [Context]
    system:    "You are harness9... working directory is: /test"
    user:      "I want to travel to Beijing today, can you check if the weather is suitable?"

  LLM call: → Generate(ctx, history, [get_weather])
    assistant: "Let me check the weather in Beijing."
               + ToolCall{id:"call_abc", name:"get_weather", args:{"city":"Beijing"}}
    → Injected into contextHistory

  ToolCall: → Registry.Execute(get_weather, {"city":"Beijing"})
    ToolResult{id:"call_abc", output:"Sunny today, low of 14 degrees..."}

  Observation: user: "Sunny today, low of 14 degrees..." (toolCallID:"call_abc")

Turn 2:
  [Context = 4 messages: system, user, assistant(+ToolCalls), user(obs)]

  LLM call: → Generate(ctx, history, [get_weather])
    assistant: "The weather in Beijing looks great today, perfect for a trip!" (no ToolCall)
    → Injected into contextHistory

  → Termination condition met, loop exits

7.1 Streaming Mode Data Flow

Taking the same task under streaming mode (RunStream) as an example, the client receives increments via the Event channel:

Turn 1:
  streamGenerate() → GenerateStream(ctx, history, [get_weather])
    Event{action_delta, "Let"}           ← token by token
    Event{action_delta, "me"}
    Event{action_delta, "check the weather in Beijing."}
    Event{tool_start, ToolCall{name:"get_weather", id:"call_abc"}}

  executeTools() → Concurrent tool execution
    Event{tool_result, ToolResult{output:"Sunny today, low of 14 degrees..."}}

Turn 2:
  streamGenerate() → GenerateStream(ctx, history, [get_weather])
    Event{action_delta, "The weather in Beijing looks great today"}   ← token by token
    Event{action_delta, ", perfect for a trip!"}
    Event{done}                              ← loop ended

8. Provider Implementation Comparison

DimensionOpenAIProviderAnthropicProvider
API ProtocolChat CompletionMessages
System promptAs a system message in the messages arrayAs an independent params.System parameter
Tool call responseToolCalls[].Function.Arguments (JSON string)Input of the tool_use block within Content[] (structured object)
Historical tool callChatCompletionMessageFunctionToolCallParamToolUseBlockParam
Tool result passbackopenai.ToolMessage(content, toolCallID)anthropic.NewToolResultBlock(toolCallID, content, isError)
InputSchema conversionconvertToFunctionParametersshared.FunctionParametersextractSchemaFieldsproperties + required
MaxTokensNot required explicitlyMust be passed explicitly
ConstructorNewOpenAIProvider(model) (*OpenAIProvider, error)NewAnthropicProvider(model, maxTokens) (*AnthropicProvider, error)
Streaming SDK methodclient.Chat.Completions.NewStreaming()client.Messages.NewStreaming()
Streaming chunk typeChatCompletionChunkMessageStreamEventUnion
Streaming text deltaChoices[0].Delta.Contentcontent_block_delta + text_delta
Streaming tool deltaChoices[0].Delta.ToolCalls[]content_block_start(tool_use) + input_json_delta

Both Providers' message conversion logic is factored out into convertMessages / convertTools methods, with Generate and GenerateStream sharing the same conversion logic. The mapping from schema.Message to native SDK parameters is encapsulated inside the Provider; the engine layer does not need to be aware of the API differences.

9. Known Limitations and Future Evolution

LimitationCurrent StatusDirection of Evolution
Context window controlImplemented: SummarizationCompactor (default, LLM summarization + incremental update), TokenBudgetCompactor (fallback), SlidingWindowCompactor (message-count window)Further optimize summary quality; support custom summary templates
Session history persistenceImplemented: SQLiteSession (WAL mode, ~/.harness9/sessions.db) + TodoStore cross-session persistenceMulti-working-directory isolation; session tagging and search (FTS5)
Streaming outputImplemented: RunStream + GenerateStream, supporting token-by-token deltas + EventTokenUpdate/EventCompactionExtend to an SSE HTTP endpoint, connecting to external real-time push channels
PlanningImplemented: Plan Mode + TodoStore + auto-continuation + stagnation detectionPlanModeAutoEdit for step-by-step confirmed edit mode
Permission controlPlan Mode provides tool-layer read-only constraintsUnified PermissionChecker before tool execution, supporting interactive confirmation
Hook systemNonePreToolUse / PostToolUse / Stop / TurnComplete event hooks
Multi-Agent orchestrationSingle-Agent modeSub-Agent scheduling, parallel Agents, dedicated role Agents

10. Summary of Design Principles

PrincipleManifestation
Standard ReActReasoning + Acting + Observation, one LLM call per Turn
emitter decouplingLoop kernel decoupled from output-side behavior; blocking / streaming share the same runLoop
Interface isolationLLMProvider and Registry each handle their own responsibilities; the engine depends only on abstractions
Dual-mode coexistenceRun (blocking) and RunStream (streaming) share the engine configuration, chosen freely at runtime
Channel-driven streamingProvider → chan StreamChunk → Engine → chan Event, native Go CSP model
Functional optionsWithMaxTurns / WithToolTimeout / WithMaxConcurrentTools optional configuration
Concurrency safetyIndex-isolated writes + WaitGroup + semaphore throttling + explicit parameter passing, no data races
Triple-guaranteed terminationNatural termination + MaxTurns limit + Context cancellation
ObservabilityStructured logging with [engine] / [engine-stream] prefixes + key=value format
Deferred parsingjson.RawMessage used for deferred Arguments deserialization; interface{} used for InputSchema compatibility across multiple SDKs
Self-healing capabilityToolResult.IsError allows the model to perceive errors and automatically retry

PromptBuilder and Skills Integration

Since the context-engineering branch, the system prompt in runLoop is no longer hardcoded, but is dynamically constructed via the PromptBuilder interface:

go
type PromptBuilder interface {
    Build() string
}

The WithPromptBuilder(pb PromptBuilder) Option injects the builder into the engine. When not set, it falls back to the built-in default text (backward compatible).

The internal/context.DefaultPromptBuilder implementation assembles the prompt in the following order:

  1. harness9 base prompt (role definition + workDir)
  2. workdir/AGENTS.md (skipped if it does not exist)
  3. Skills index summary (from internal/skills.Index.Summary())

The full content of Skills is loaded on demand via the use_skill tool (Progressive Disclosure), which does not affect the execution logic of the base ReAct loop.

Released under the MIT License.