Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export OPENAI_MODEL="model-name"
* **True cl100k BPE Offline Tokenizer:** Embedded tokenizer calculates real BPE token counts offline with zero heuristic guesswork.
* **Native Context-Aware Search:** High-performance codebase search with globster filtering that respects `.gitignore` and `.llmignore`.
* **Agent Skills & MCP Support:** Natively consume external Model Context Protocol (MCP) servers and third-party Agent Skills with zero configuration overhead.
* **Tool-Output Archiving (default, no config):** Tool outputs over 1024 characters are archived verbatim under the session's folder (`<sessions>/<sessionID>/tool-outputs/<sha256[:16]>.txt`, content-addressed so identical outputs dedupe). What enters the conversation depends on size: at or under 3072 characters the full text stays inline (the archive is only a durability backup); above 3072 it is replaced by a deterministic compact reference — the first 2000 characters, a `[output truncated: N chars total. … Full output archived: <path> — read_file it to view everything]` marker (it names the sanctioned retrieval tool; `cat`/`grep` are gated), and the last 500 characters, so the end of the output is visible without a second read. The reference is written once at admission and never rewritten (prompt-cache stable), and any archive error fails open to the inline output (see the Quickstart).
* **Git Worktree Support:** Run independent, parallel agent instances across multiple branches simultaneously with zero context bleeding.

---
Expand Down
40 changes: 40 additions & 0 deletions cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ import (
// this boolean flag as taking an argument.
const askForUserApprovalUsage = "Require explicit user approval before running potentially dangerous commands (default; overrides config.json permission-mode)."

// forceRevaluateUsage is the -h description of
// -force-revaluate-dangerous-commands.
//
// IMPORTANT: this string must contain no back-quoted word. flag.PrintDefaults
// renders the first back-quoted word of a usage string as the flag's value
// name, which would advertise the flag as taking an argument. The OTP code is
// never passed on the CLI: late generates a random single-use code at runtime
// and hands it to the agent in the tool-result block message; the agent
// re-runs the command passing it in the bash tool's otp_code parameter.
const forceRevaluateUsage = "Unsupervised execution, but the first attempt to run a potentially dangerous command is blocked; late issues the agent a random single-use OTP code, bound to that exact command, which it must pass in the bash tool's otp_code parameter to re-run."

// pluginInlineTool adapts a plugin.InlineTool (defined in internal/plugin/tools.go)
// into a common.Tool so the CLI's session registry can dispatch invocations to
// plugin-declared runners. It exists because upstream repurposed
Expand Down Expand Up @@ -110,6 +121,7 @@ func main() {
versionReq := flag.Bool("version", false, "Print the version and exit.")
unsupervisedReq := flag.Bool("i-promise-i-have-backups-and-will-not-file-issues", false, "UNSUPPORTED: run every tool without user confirmation.")
askForUserApprovalReq := flag.Bool("ask-for-user-approval", false, askForUserApprovalUsage)
forceRevaluateReq := flag.Bool("force-revaluate-dangerous-commands", false, forceRevaluateUsage)
enableImagesReq := flag.Bool("enable-images", false, "Force-enable image attachments even if the backend does not advertise vision support.")
continueReq := flag.Bool("continue", false, "Resume the most recently updated session, regardless of which project directory it was started in.")
continueProjectReq := flag.Bool("continue-project", false, "Resume the most recently updated session for the current project (git repo root of the working directory, or the working directory outside a repo); mutually exclusive with -continue.")
Expand Down Expand Up @@ -541,6 +553,27 @@ func main() {
}
}

// Tool-output archiving (default behavior, no config key): oversized
// tool outputs are written under the session's folder and the
// conversation carries only the compact reference form. One archive for
// the run — the root agent and every subagent share it. Rooted at the
// active session's folder so the archive dies with the session folder
// (RemoveSessionFolder); an in-memory session (no derived session ID)
// fails the validity check and simply gets no archive — everything
// stays inline.
toolArchiveDir, archiveDirErr := session.OutputArchiveDir(effectiveSessionID)
if archiveDirErr == nil {
arch, archErr := session.NewOutputArchive(toolArchiveDir)
if archErr != nil {
// Archiving is fail-open end to end: ExecuteToolCalls keeps
// results inline on any archive error, so a failed setup only
// disables the feature.
fmt.Fprintf(os.Stderr, "Warning: tool-output archiving disabled (%v)\n", archErr)
} else {
executor.SetToolResultArchiver(arch)
}
}

// Resolve theme: --theme flag > $LATE_THEME > config.json > bundled base.
themeID := *themeReq
if themeID == "" {
Expand Down Expand Up @@ -743,6 +776,13 @@ func main() {
case appconfig.PermissionModeUnsupervised:
ctx = context.WithValue(ctx, common.SkipConfirmationKey, true)
}
if *forceRevaluateReq {
// Force-revaluate runs unsupervised, but the first attempt of
// every dangerous command is still gated behind a single-use OTP
// the agent must echo back (see internal/tool/otp.go).
ctx = context.WithValue(ctx, common.SkipConfirmationKey, true)
ctx = context.WithValue(ctx, common.ForceRevaluateKey, true)
}
ctx = context.WithValue(ctx, common.MaxStreamRetriesKey, *maxStreamRetries)
rootAgent.SetContext(ctx)

Expand Down
20 changes: 20 additions & 0 deletions cmd/late/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,23 @@ func TestPermissionFlagUsageRendersWithoutValueName(t *testing.T) {
t.Errorf("expected no rendered value name for this boolean flag, got %q (help would show -ask-for-user-approval %s)", name, name)
}
}

// TestForceRevaluateUsageRendersWithoutValueName guards the -h output of
// -force-revaluate-dangerous-commands: the usage string must contain no
// back-quoted word, because flag.UnquoteUsage turns the first back-quoted
// word into the flag's value name and PrintDefaults would then render the
// boolean flag as taking an argument (e.g. "-force-revaluate-dangerous-commands otp_code"),
// wrongly implying the OTP is passed on the CLI. The
// OTP is generated by late at runtime and delivered to the agent in the
// block message; it is never a flag argument.
func TestForceRevaluateUsageRendersWithoutValueName(t *testing.T) {
if strings.ContainsRune(forceRevaluateUsage, '`') {
t.Fatalf("forceRevaluateUsage must not contain backquotes (flag.UnquoteUsage would render the quoted word as the flag's value name): %q", forceRevaluateUsage)
}
fs := flag.NewFlagSet("usage-test", flag.ContinueOnError)
fs.Bool("force-revaluate-dangerous-commands", false, forceRevaluateUsage)
name, _ := flag.UnquoteUsage(fs.Lookup("force-revaluate-dangerous-commands"))
if name != "" {
t.Errorf("expected no rendered value name for this boolean flag, got %q (help would show -force-revaluate-dangerous-commands %s)", name, name)
}
}
7 changes: 7 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ Type `/` at any time to open the command picker.

When Late creates subagents, each appears in its own tab while it works and disappears after completing its task.

---

## Tool-Output Archiving

Independent of any other context management (default behavior, no config): tool outputs over 1024 characters are archived verbatim under the session's folder (`<sessions>/<sessionID>/tool-outputs/<sha256[:16]>.txt`). What enters the conversation depends on size — the rule is hysteresis-free (fixed byte-length bands, no state): at or under 3072 characters the full text stays inline (the archive is only a durability backup, no marker is added); above 3072 it is replaced by a compact reference form — the first 2000 characters, a `…[output truncated: N chars total. Head above / tail below. Full output archived: <path> — read_file it to view everything]` marker carrying the total size, and the last 500 characters, so the end of the output (errors, summaries) is visible without a second read. The marker names the sanctioned retrieval tool (`read_file <path>`); `cat`/`grep` are gated, and `read_file` accepts the absolute archive path as-is. The reference is content-addressed and deterministic (identical output → identical bytes), it is written once and never rewritten, and on any archive error the full output stays inline.


---

## Tool Approval
Expand Down
96 changes: 96 additions & 0 deletions install-dev.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# install-dev.sh — replace the installed `late` command with a symlink into
# this repository's locally built binary, so every `make build` updates the
# command in place. Nothing is copied.
#
# Link target:
# - with Homebrew present (and late not brew-managed): brew's bin dir
# - without Homebrew: the first bin dir that is already on PATH and that we
# can write to or create, in this order: ~/.local/bin, ~/bin,
# /usr/local/bin. If none is on PATH, the script falls back to
# ~/.local/bin and tells you to add it to your PATH.
#
# Usage:
# brew uninstall late # only when late is brew-managed
# ./install-dev.sh # optionally: LATE_DEV_VERSION=x ./install-dev.sh
#
# Go back to brew:
# rm "$(brew --prefix)/bin/late" && brew install late
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$SCRIPT_DIR"
DEV_LINK="$REPO/bin/late"

# Refuse to fight an active brew install: replacing brew's symlink out from
# under it would leave brew's link tracking desynced.
if command -v brew >/dev/null 2>&1; then
if brew list --formula 2>/dev/null | grep -qx 'late' || brew list --cask 2>/dev/null | grep -qx 'late'; then
echo "Error: late is still installed via brew — run: brew uninstall late" >&2
exit 1
fi
BIN_DIR="$(brew --prefix)/bin"
else
# No Homebrew: pick the first bin dir that is already on PATH and that we
# can write to (or create), so `late` can be invoked from anywhere.
BIN_DIR=""
for candidate in "$HOME/.local/bin" "$HOME/bin" /usr/local/bin; do
if [[ ":$PATH:" != *":$candidate:"* ]]; then
continue
fi
if [[ -d "$candidate" && -w "$candidate" ]]; then
BIN_DIR="$candidate"
break
fi
if [[ ! -e "$candidate" ]]; then
mkdir -p "$candidate"
BIN_DIR="$candidate"
break
fi
done
if [[ -z "$BIN_DIR" ]]; then
# Nothing suitable on PATH: default to ~/.local/bin (XDG convention)
# and remind the user to add it below.
BIN_DIR="$HOME/.local/bin"
mkdir -p "$BIN_DIR"
fi
fi

echo "=> Building late from $REPO..."
if [[ -n "${LATE_DEV_VERSION:-}" ]]; then
make -C "$REPO" build VERSION="$LATE_DEV_VERSION"
else
make -C "$REPO" build
fi

# Never clobber a real binary: the destination must be a symlink (a previous
# dev install) or absent. Anything else gets removed by hand, deliberately.
if [[ -e "$BIN_DIR/late" && ! -L "$BIN_DIR/late" ]]; then
echo "Error: $BIN_DIR/late already exists and is not a symlink." >&2
echo "Remove or rename it manually, then re-run this script." >&2
exit 1
fi

echo "=> Symlinking $BIN_DIR/late -> $DEV_LINK"
ln -sfn "$DEV_LINK" "$BIN_DIR/late"

# Friction checks: the link must be reachable and must not be shadowed by
# another `late` sitting earlier on PATH.
resolved="$(command -v late 2>/dev/null || true)"
if [[ -z "$resolved" ]]; then
echo "" >&2
echo "⚠️ 'late' is not on your PATH — add this to your ~/.zshrc or ~/.bashrc:" >&2
echo " export PATH=\"\$PATH:$BIN_DIR\"" >&2
exit 1
fi
if [[ "$resolved" != "$BIN_DIR/late" ]]; then
echo "" >&2
echo "⚠️ 'late' currently resolves to: $resolved" >&2
echo " which shadows $BIN_DIR/late — adjust your PATH order or remove" >&2
echo " that binary, then re-run this script." >&2
exit 1
fi

echo "=> Success! 'late' now resolves to:"
command -v late
late -version
1 change: 1 addition & 0 deletions internal/common/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const (
ToolApprovalKey contextKey = "tool_approval"
MaxStreamRetriesKey contextKey = "max_stream_retries"
MaxBadBodyRetriesKey contextKey = "max_bad_body_retries"
ForceRevaluateKey contextKey = "force_revaluate"
)

// MainAgentID is the orchestrator ID of the root/main agent.
Expand Down
97 changes: 97 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"time"

Expand Down Expand Up @@ -68,6 +69,95 @@ func (a *StreamAccumulator) Reset() {

// --- Tool Execution ---

// ArchiveThresholdChars is the tool-result size above which ExecuteToolCalls
// archives the full output to disk under the session folder
// (session.OutputArchive). Results at or under the threshold enter history
// inline, unchanged.
const ArchiveThresholdChars = 1024

// ArchiveInlineFullChars is the upper bound of the inline-first band.
// Results above ArchiveThresholdChars (1024) but at or under this size are
// archived for durability yet returned to history FULL — the text is small
// enough to be immediately useful, and the archive is only a backup. Above
// this size the result enters history as the compact reference form
// (session.FormatReference: head + tail + archive pointer).
//
// The banding rule is hysteresis-free: two byte-length comparisons against
// these fixed constants, no state and no time-dependence, so the same
// output always maps to the same history form.
const ArchiveInlineFullChars = 3072

// ArchiveHeadChars is how many leading characters of the original output
// the archived reference form keeps inline.
const ArchiveHeadChars = 2000

// ArchiveTailChars is how many trailing characters of the original output
// the archived reference form keeps inline (after the archive marker), so
// the agent sees how the output ended — errors, summaries, final lines
// usually live there — without a second read. The reference form is only
// rendered above ArchiveInlineFullChars (3072 > ArchiveHeadChars +
// ArchiveTailChars = 2500), so head and tail never overlap in practice.
const ArchiveTailChars = 500

// toolResultArchiver is the process-wide output archive consulted by
// ExecuteToolCalls before a tool result enters history. One archive per
// session folder; the root agent and every subagent share the instance
// installed for the run, so a child's oversized outputs land in the same
// session folder.
var (
toolResultArchiverMu sync.RWMutex
toolResultArchiver *session.OutputArchive
)

// SetToolResultArchiver installs a as the process-wide tool-output archive
// for ExecuteToolCalls — the root agent and every subagent share it.
// Install it once at startup (cmd/late) with the archive rooted at the
// active session's folder. Pass nil to disable archiving.
func SetToolResultArchiver(a *session.OutputArchive) {
toolResultArchiverMu.Lock()
defer toolResultArchiverMu.Unlock()
toolResultArchiver = a
}

// maybeArchiveToolResult returns the (possibly archived) form of result for
// history. Three bands, evaluated hysteresis-free against fixed constants:
//
// - no archive installed → inline unchanged;
// - result at or under ArchiveThresholdChars → inline unchanged;
// - above ArchiveThresholdChars and at or under ArchiveInlineFullChars
// (3072) → inline-first: a copy is archived for durability, but the
// FULL text stays inline — at this size it is immediately useful and
// the archive is only a backup, so no marker is added.
// - above ArchiveInlineFullChars → archived and replaced by the compact,
// deterministic reference form (head + tail + self-documenting archive
// pointer naming read_file; see session.FormatReference).
//
// Fail-open: an Archive error is logged and the full output stays inline —
// a full-disk condition must never lose a tool result from the
// conversation. On success in the reference band, the reference form (small)
// is what history stores; the session.OutputArchive determinism contract
// guarantees it is byte-identical for identical outputs, and it is generated
// once here at admission and never regenerated or substituted afterward.
func maybeArchiveToolResult(toolName, result string) string {
toolResultArchiverMu.RLock()
a := toolResultArchiver
toolResultArchiverMu.RUnlock()
if a == nil || len(result) <= ArchiveThresholdChars {
return result
}
archived, err := a.Archive(result)
if err != nil {
fmt.Fprintf(os.Stderr, "late: failed to archive %s tool output (%d chars), keeping it inline: %v\n", toolName, len(result), err)
return result
}
if len(result) <= ArchiveInlineFullChars {
// Inline-first band: the archive copy exists for durability, the
// conversation keeps the full text.
return result
}
return session.FormatReference(archived, result, ArchiveHeadChars, ArchiveTailChars)
}

// ExecuteToolCalls runs a slice of tool calls against the session.
// It uses the provided middlewares to wrap the base tool execution.
// Results are added to the session history.
Expand Down Expand Up @@ -107,6 +197,13 @@ func ExecuteToolCalls(ctx context.Context, sess *session.Session, toolCalls []cl
if err != nil {
result = fmt.Sprintf("Error executing tool %s: %v", tc.Function.Name, err)
}
// Output archiving: oversized results are stored on disk and
// replaced by the compact reference form BEFORE entering history.
// Determinism (prompt-cache stability): the reference form is
// generated once, here, and the bytes stored in history are final —
// the request renderer uses history verbatim and nothing ever
// substitutes the archived content back in.
result = maybeArchiveToolResult(tc.Function.Name, result)
if err := sess.AddToolResultMessage(tc.ID, result); err != nil {
return err
}
Expand Down
Loading
Loading