agent

package
v0.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package agent implements the local Agent-loop: the canonical "while the model requests tools, execute them and feed results back" cycle (Phase 0 — read-only). It is deliberately model-backend-agnostic (any OpenAI-compatible tool-calling Client) and side-effect-agnostic (tools are injected), so the loop logic is unit-testable without a live model or real tools. Stop conditions and the step budget are owned HERE, in code — never by the model — and an erroring or unknown tool is fed back as data (defer-not- crash), never a panic or an aborted run.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Mem0KeyFromEnvOrFile

func Mem0KeyFromEnvOrFile() string

Mem0KeyFromEnvOrFile resolves the mem0 API key: $MEM0_API_KEY first, then the on-disk key file (~/.mem0/api-key, mode 600 — the mem0 server's own key source). Returns "" if neither is present (memory then stays disabled rather than crashing).

func SystemPrompt

func SystemPrompt(allowWrite, allowFetch, allowShell bool) string

SystemPrompt is the loop's capability-aware system prompt: it advertises only the tools actually granted (read-only by default, +write/+web_fetch/+run_shell when enabled) and labels fetched web content as untrusted data. It is shared by every drive mode (headless CLI, MCP front door, standalone) so the agent's behavior is identical regardless of who supplies the goal.

Types

type Action

type Action struct {
	Kind   ActionKind
	Path   string
	Exists bool
}

Action is a proposed mutating operation. Path is worktree-relative (the worktree FS boundary itself is enforced separately by os.Root); Exists tells the broker whether the target already exists (new vs overwrite).

type ActionKind

type ActionKind string

ActionKind is the class of mutating action the broker gates. P2 covers file mutations; later phases extend this (shell, network) behind the same broker.

const (
	ActWrite  ActionKind = "write"
	ActDelete ActionKind = "delete"
	ActFetch  ActionKind = "fetch" // P3: outbound HTTP(S) GET to a host (egress-allowlist gated)
	ActShell  ActionKind = "shell" // P4.6: run a command inside the OS sandbox (opt-in, audited)
)

type Allowlist

type Allowlist struct {
	// contains filtered or unexported fields
}

Allowlist is the P3 egress host allowlist: a set of canonical hosts plus opt-in, boundary-anchored wildcard suffixes. It is built once and never mutated, so the policy broker that reads it stays deterministic. The zero value permits NOTHING (default-deny) — exactly how P0/P1 grant no network tool at all. The host-matching rule is the security boundary; see canonHost.

func NewAllowlist

func NewAllowlist(entries []string) (Allowlist, error)

NewAllowlist parses bare-hostname entries and "*.host" wildcards into an Allowlist, rejecting any entry that carries a scheme, port, path, userinfo, or backslash (bare hostnames only — the Managed-Agents rule). Empty input yields the deny-all zero value. A "*." prefix becomes a boundary-anchored suffix; any other "*" (mid-label, bare) is rejected by canonHost.

type AuditLog

type AuditLog struct {
	// contains filtered or unexported fields
}

AuditLog is an append-only JSONL record of every brokered decision — kept separate from the savings ledger, never co-mingled.

func NewAuditLog

func NewAuditLog(path string) *AuditLog

NewAuditLog returns a logger writing to path (created on first record).

func (*AuditLog) Record

func (l *AuditLog) Record(a Action, d Decision, reason string) error

Record appends one decision as a JSON line and RETURNS any error so the broker can refuse to perform an unauditable allow. A nil receiver is a no-op (nil err).

type BuildConfig

type BuildConfig struct {
	PlannerBase string        // local model base URL (no /v1); required
	Model       string        // planner model id (must support tool-calling); required
	Timeout     time.Duration // per planner-call timeout; default 180s
	MaxSteps    int           // hard step budget; default 12
	MaxTokens   int           // planner max tokens per call; default 1024

	ReadRoot string      // directory the agent may read (P0 scope); required
	Offload  OffloadFunc // in-process offload (record=false); nil => no offload tools

	// SystemPromptOverride, when set, replaces the capability-aware system prompt
	// (P6 flywheel replay evaluates a CANDIDATE planner prompt). Empty => the normal
	// SystemPrompt. The tool set is still capability-gated as usual, so a read-only
	// build stays side-effect-free regardless of the prompt.
	SystemPromptOverride string

	Unattended   bool   // true => every broker "ask" deny-and-queues (no human in the loop)
	AuditPath    string // append-only broker audit JSONL; must live OUTSIDE the worktree
	AskQueuePath string // P5b: reviewable queue of asks deferred on an unattended run (optional)

	AllowWrite  bool     // P2: write_file/delete_file in the worktree
	AllowFetch  bool     // P3: web_fetch behind the egress allowlist
	AllowShell  bool     // P4.6: run_shell in the OS cage (granted only if sandbox.Available)
	Worktree    string   // RW worktree for write/shell; default = ReadRoot
	EgressHosts []string // web_fetch allowlist (AllowFetch)

	Memory Memory // optional mem0 layer; nil => no memory
}

BuildConfig declares everything a drive mode needs to construct the agent loop identically. The caller supplies the local planner endpoint, the read scope, an in-process offload closure (record=false — e.g. pipeline.NewRecordlessOffload), and which capabilities are opt-in enabled. Build wires the tools + the single deny→ask→allow broker + the loop the SAME way for every mode (CLI, MCP front door, standalone), so the three modes cannot drift.

type BuildResult

type BuildResult struct {
	Loop         *Loop
	Tools        []Tool
	Policy       *Policy
	Worktree     string // resolved RW worktree (empty if no write/shell)
	ShellGranted bool
	Notes        []string
}

BuildResult is the assembled loop plus what was actually granted. ShellGranted is false when --allow-shell was requested but the OS cage is unavailable here (fail-closed); Notes are human-readable capability lines the caller can log.

func Build

func Build(cfg BuildConfig) (*BuildResult, error)

Build assembles the agent loop for any drive mode. It is the SINGLE place the tool set + broker + loop are constructed, so the CLI, the MCP front door, and the standalone runner stay at parity by construction. Operational failures (bad paths, audit-inside-worktree, worktree creation) are returned as errors, never os.Exit — the caller decides how to surface them.

type Client

type Client interface {
	Chat(ctx context.Context, msgs []Msg, tools []ToolSpec, maxTokens int) (Completion, error)
}

Client is the minimal OpenAI-compatible tool-calling chat interface the loop needs. A concrete implementation targets llama-swap / NIM; tests use a fake.

type Completion

type Completion struct {
	Msg          Msg
	FinishReason string
}

Completion is one model turn: the assistant message plus the finish reason ("tool_calls" => the loop must execute tools and continue; anything else => the loop stops).

type Decision

type Decision string

Decision is the broker's verdict on a mutating action.

const (
	Allow Decision = "allow" // perform it
	Ask   Decision = "ask"   // needs human approval (unattended => deny-and-queue)
	Deny  Decision = "deny"  // never perform
)

type LLMClient

type LLMClient struct {
	// contains filtered or unexported fields
}

LLMClient is the concrete OpenAI-compatible tool-calling Client. It targets any /v1/chat/completions endpoint — llama-swap (:11436, keyless) by default, or an NIM endpoint (apiKey set) for the hybrid "ask" escalation. One type, base_url swap. It implements the agent.Client interface.

func NewLLMClient

func NewLLMClient(base, model, apiKey string, timeout time.Duration) *LLMClient

NewLLMClient builds a client. base is the server root (no /v1); apiKey "" => no Authorization header (local).

func (*LLMClient) Chat

func (c *LLMClient) Chat(ctx context.Context, msgs []Msg, tools []ToolSpec, maxTokens int) (Completion, error)

Chat sends the running transcript + tool specs and returns the next completion.

type Loop

type Loop struct {
	// contains filtered or unexported fields
}

Loop runs the canonical agent loop over a fixed tool set.

func NewLoop

func NewLoop(c Client, tools []Tool, maxSteps int) *Loop

NewLoop builds a loop. maxSteps is the hard budget guard (owned in code, not the prompt). A non-positive maxSteps defaults to 1.

func (*Loop) Run

func (l *Loop) Run(ctx context.Context, objective string) (Result, error)

Run executes the loop for objective until the model stops, the step budget is exhausted, or the context is cancelled.

func (*Loop) WithMaxTokens

func (l *Loop) WithMaxTokens(n int) *Loop

func (*Loop) WithMemory

func (l *Loop) WithMemory(m Memory) *Loop

WithMemory attaches a memory layer: the loop recalls relevant context before planning and persists the run outcome when it finishes. nil = no memory.

func (*Loop) WithSystem

func (l *Loop) WithSystem(s string) *Loop

WithSystem sets an optional system prompt. WithMaxTokens overrides the per-call completion cap.

type Memory

type Memory interface {
	Recall(ctx context.Context, query string, limit int) ([]Recalled, error)
	Persist(ctx context.Context, text string, meta map[string]string) (string, error)
}

Memory is the loop's optional memory layer: recall relevant context before planning, persist a run outcome after. nil => the loop runs without memory.

type MemoryClient

type MemoryClient struct {
	// contains filtered or unexported fields
}

MemoryClient talks to the mem0 REST server (the agentic-memory system). It is WRITE-ISOLATED by construction: it recalls across readUsers (e.g. the agent's own namespace for run-to-run continuity + the canonical "dmmdea" namespace for Daniel's knowledge) but only ever WRITES under writeUser, and only at the "evidence" tier — the mem0 server independently rejects any attempt to write the canonical tier (403), so the agent cannot pollute the canonical anchor.

func NewMemoryClient

func NewMemoryClient(base, apiKey string, readUsers []string, writeUser, agentID string, timeout time.Duration) *MemoryClient

NewMemoryClient builds a client. readUsers are the namespaces to recall from; writeUser is the (isolated) namespace to persist to. A trailing slash on base is trimmed.

func (*MemoryClient) Persist

func (c *MemoryClient) Persist(ctx context.Context, text string, meta map[string]string) (string, error)

Persist writes text as an EVIDENCE-tier memory under the isolated writeUser namespace, tagged source=local-agent. It NEVER sends tier=canonical (the server would 403 anyway) — the agent cannot reach the canonical anchor.

func (*MemoryClient) Recall

func (c *MemoryClient) Recall(ctx context.Context, query string, limit int) ([]Recalled, error)

Recall searches each readUser namespace CONCURRENTLY under one short deadline and returns the merged, score-sorted, de-duplicated top-`limit` memories. A per-namespace error is tolerated (that namespace contributes nothing); a total failure returns the last error with whatever was gathered — the caller treats an empty recall as "no memory". Concurrency + the deadline mean a single slow/ wedged namespace can't stall planning.

type Msg

type Msg struct {
	Role       string
	Content    string
	ToolCalls  []ToolCall
	ToolCallID string
	IsError    bool
}

Msg is one chat message in the loop's running transcript. Role is "system" | "user" | "assistant" | "tool". A tool result carries ToolCallID (matching the originating ToolCall.ID) and IsError when the tool failed.

type OffloadFunc

type OffloadFunc func(ctx context.Context, task, input string, params map[string]any) (result string, err error)

OffloadFunc is the in-process offload capability injected by cmd/local-agent (wired to pipeline.RunTier, which is record=false → no ledger/cache/shadow/ exemplar writes). Keeping it an injected func keeps this package free of any pipeline import and unit-testable with a fake. nil => no offload tools.

type Policy

type Policy struct {
	// contains filtered or unexported fields
}

Policy is the deterministic deny→ask→allow broker — the cage that must exist BEFORE any mutating tool is granted. It is the single chokepoint for every effectful action. For an UNATTENDED run, "ask" resolves to deny-and-queue (never proceed without a human), making approval-before-destructive a mechanism rather than a hope.

func NewPolicy

func NewPolicy(unattended bool, audit *AuditLog) *Policy

NewPolicy builds a broker. unattended=true makes every "ask" deny-and-queue. audit may be nil (decisions then aren't logged). Egress is deny-all (no allowlist) — use NewPolicyWithEgress to grant outbound hosts.

func NewPolicyWithEgress

func NewPolicyWithEgress(unattended bool, audit *AuditLog, allow Allowlist) *Policy

NewPolicyWithEgress is NewPolicy plus a P3 egress allowlist (the set of hosts web_fetch may reach). An empty allowlist is default-deny. The allowlist is set once and never mutated afterward, so classify stays pure and deterministic.

func (*Policy) Decide

func (p *Policy) Decide(a Action) (Decision, string)

Decide returns the EFFECTIVE decision (resolving ask→deny for unattended runs) plus a reason, and records it to the audit log. This is what tools call. If an ALLOW cannot be audited, it is downgraded to DENY — an unauditable mutation is not performed (a mutating agent must leave a trail).

func (*Policy) WithAskQueue

func (p *Policy) WithAskQueue(q *AuditLog) *Policy

WithAskQueue attaches a reviewable queue that records every ask DEFERRED on an unattended run (so a human can review/approve them later). Off by default; the standalone runner sets it. The action is still denied in the moment (unattended ask → deny-and-queue) — this just parks the pending request for review.

func (*Policy) WithShell

func (p *Policy) WithShell(allowed bool) *Policy

WithShell enables (or disables) the ActShell capability. Off by default; the CLI turns it on only when --allow-shell is set AND the OS sandbox is available. Set once at startup before any Decide, so classify stays deterministic.

type Recalled

type Recalled struct {
	ID     string
	Text   string
	Score  float64
	Source string // which namespace (user_id) it came from
}

Recalled is one retrieved memory.

type Result

type Result struct {
	Output     string // the final assistant content
	Steps      int    // model turns taken
	StopReason string // "done" (model finished) | "budget" (hit maxSteps) | "error"
	Transcript []Msg
}

Result is the outcome of a loop run.

type Tool

type Tool struct {
	ToolSpec
	Exec func(ctx context.Context, args string) (string, error)
}

Tool is a ToolSpec plus its executor. Exec receives the raw JSON args and returns a result string; an error is fed back to the model as an is_error tool result (the loop does not abort).

func FetchTools

func FetchTools(pol *Policy) []Tool

FetchTools builds the P3 web_fetch tool, registered ONLY when egress is opted in. With no allowlist the broker denies every host, so the network capability is inert by default — mirroring how P0/P1 advertise no network tool at all.

func ReadOnlyTools

func ReadOnlyTools(root string, offload OffloadFunc) ([]Tool, error)

ReadOnlyTools builds the Phase-0 tool set, scoped to root (the worktree): list_dir + read_file (both confined to root), plus the offload_* tools when an offloader is wired. It registers NO write/shell/network tool — P0 is read-only by construction; broader capability is gated to later phases behind the cage.

Containment is OS-ENFORCED via os.Root: every file access goes through a root handle that refuses to escape — `..`, absolute paths, AND reparse points (symlinks / Windows directory junctions) are all rejected by the kernel-level openat-style traversal, and it FAILS CLOSED (any resolution error => the tool errors, never a silent fall-through). This replaces the earlier hand-rolled EvalSymlinks check, which a fresh-context review proved bypassable by a non-privileged Windows junction.

func ShellTools

func ShellTools(pol *Policy, worktree, scratch string) []Tool

ShellTools builds the P4.6 run_shell tool: it executes a command inside the OS sandbox (no network, filesystem confined to the worktree (RW) + scratch, dangerous syscalls blocked, i386 ABI closed). It is registered ONLY when the caller has opted in AND sandbox.Available() is true; it is gated AGAIN at runtime by the deny→ask→allow broker, which audits every command. worktree is the RW working directory; scratch is a RW temp dir.

func WriteTools

func WriteTools(worktreeRoot string, pol *Policy) ([]Tool, error)

WriteTools builds the Phase-2 MUTATING tools (write_file, delete_file), confined to worktreeRoot via os.Root (the same kernel-enforced, fail-closed, reparse-safe containment used for reads) AND gated by the deny→ask→allow policy broker (the cage). Every mutation is brokered + audited; a non-Allow decision returns a "NOT performed" tool result (defer-not-crash) — the file is untouched and the model can react. These tools are registered ONLY when the caller opts into write capability; P0/P1 stay read-only.

type ToolCall

type ToolCall struct {
	ID   string
	Name string
	Args string // raw JSON arguments
}

ToolCall is one tool invocation the model requested.

type ToolSpec

type ToolSpec struct {
	Name        string
	Description string
	Schema      json.RawMessage
}

ToolSpec is the declarative surface advertised to the model.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL