Skip to content
Merged
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
4 changes: 3 additions & 1 deletion cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,11 @@ func Execute() {
}

// printError logs a failed command's error as JSON for a collecting parent,
// and otherwise prints it styled for a person.
// and otherwise prints it styled for a person. The text keeps its case, as fang
// would title-case its first word and turn a flag like --model into --Model.
func printError(w io.Writer, styles fang.Styles, err error) {
if !logger.ReportExitError(os.Stderr, err) {
styles.ErrorText = styles.ErrorText.UnsetTransform()
fang.DefaultErrorHandler(w, styles, err)
}
}
Expand Down
15 changes: 15 additions & 0 deletions cmd/root/root_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package root

import (
"bytes"
"errors"
"slices"
"strings"
"testing"

require "github.com/stretchr/testify/require"

fang "charm.land/fang/v2"
lipgloss "charm.land/lipgloss/v2"

output "github.com/inference-gateway/cli/cmd/output"
)

Expand Down Expand Up @@ -49,3 +55,12 @@ func TestNewCommandReturnsIndependentTrees(t *testing.T) {
require.NoError(t, err)
require.NotSame(t, firstChat, secondChat)
}

func TestPrintError_KeepsTheCaseOfTheMessage(t *testing.T) {
var out bytes.Buffer
styles := fang.Styles{ErrorText: lipgloss.NewStyle().Transform(strings.ToUpper)}

printError(&out, styles, errors.New("--model is required"))

require.Contains(t, out.String(), "--model is required")
}
2 changes: 1 addition & 1 deletion internal/agent/loop/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ func (p *eventPublisher) publishBashOutputChunk(callID string, output string, is
select {
case p.chatEvents <- event:
default:
logger.Warn("bash output chunk dropped - channel full")
logger.Debug("bash output chunk dropped - channel full")
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/agent/loop/testdata/tool_definitions.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@
},
{
"function": {
"description": "A powerful search tool with configurable backend (ripgrep or Go implementation)\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., \"log.*Error\", \"function\\s+\\w+\")\n - Filter files with glob parameter (e.g., \"*.js\", \"**/*.tsx\") or type parameter (e.g., \"js\", \"py\", \"rust\")\n - Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts\n - Use the Agent tool for open-ended searches requiring multiple rounds\n - Pattern syntax: When using ripgrep backend - literal braces need escaping (use `interface\\{\\}` to find `any` in Go code)\n - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \\{[\\s\\S]*?field`, use `multiline: true`\n",
"description": "A powerful search tool with configurable backend (ripgrep or Go implementation)\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Regex syntax is RE2 (e.g., \"log.*Error\", \"function\\s+\\w+\") - no lookahead, lookbehind or backreferences\n - Filter files with glob parameter (e.g., \"*.js\", \"**/*.tsx\") or type parameter (e.g., \"js\", \"py\", \"rust\")\n - Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts\n - Use the Agent tool for open-ended searches requiring multiple rounds\n - Pattern syntax: When using ripgrep backend - literal braces need escaping (use `interface\\{\\}` to find `any` in Go code)\n - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \\{[\\s\\S]*?field`, use `multiline: true`\n",
"name": "Grep",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
Expand Down Expand Up @@ -1348,7 +1348,7 @@
"type": "string"
},
"submit": {
"description": "Interactive subagents only. Press Enter to submit a prompt and wait for the subagent to finish (default true). Set false to only send keys for TUI navigation - then inspect with ReadSubagentScreen.",
"description": "Interactive subagents only. Press Enter to submit the prompt (default true). Set false to only send keys for TUI navigation - then inspect with ReadSubagentScreen.",
"type": "boolean"
},
"text": {
Expand Down
10 changes: 5 additions & 5 deletions internal/gateway/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,17 +162,17 @@ func (gm *Supervisor) startContainer(ctx context.Context) error {
}
}

if err := gm.pullImage(ctx); err != nil {
logger.Warn("failed to pull image, attempting to use local image", "error", err)
fmt.Println("• Could not pull latest image, using cached version")
}

if gm.containerRuntime != nil {
if err := gm.containerRuntime.EnsureNetwork(ctx); err != nil {
logger.Warn("failed to create Docker network", "session", gm.sessionID, "error", err)
}
}

if err := gm.pullImage(ctx); err != nil {
logger.Warn("failed to pull image, attempting to use local image", "error", err)
fmt.Println("• Could not pull latest image, using cached version")
}

if gm.config.Gateway.Debug {
fmt.Println("• Debug mode enabled - Gateway is running in development mode with detailed logging")
}
Expand Down
35 changes: 19 additions & 16 deletions internal/insights/insights.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func (g *Generator) Generate(ctx context.Context, since time.Time, progress io.W

memory := g.memoryIndex()

_, _ = fmt.Fprintf(progress, "Read %d sessions and %d log failure groups\n", len(sessions), len(logs.Groups))
_, _ = fmt.Fprintf(progress, "Read %d sessions and %d log groups\n", len(sessions), len(logs.Groups))
_, _ = fmt.Fprintf(progress, "Analyzing with %s, can take minutes...\n", model)
analysis, usage, err := g.analyze(ctx, model, buildDigest(sessions, failures, tools, memory, logs))
if err != nil {
Expand Down Expand Up @@ -370,17 +370,18 @@ func buildDigest(sessions []sessionDigest, failures []toolFailure, tools []telem
}

if len(logs.Groups) > 0 {
b.WriteString("\nLOG FAILURES (from the log files, most frequent first)\n")
b.WriteString("These are failures the sessions above never recorded - crashes, startup and\n")
b.WriteString("background errors. xN is how many times that same line recurred: a high N\n")
b.WriteString("over a short span is a retry loop that never succeeded, the same N spread\n")
b.WriteString("over days is a chronic fault. Near-identical lines are already folded together.\n")
b.WriteString("\nLOG WARNINGS AND ERRORS (from the log files, most frequent first)\n")
b.WriteString("A line naming a tool repeats a failed tool call the sessions above already\n")
b.WriteString("counted. The rest never reached a session - crashes, startup and background\n")
b.WriteString("problems. xN is how many times that same line recurred: a high N over a short\n")
b.WriteString("span is a retry loop that never succeeded, the same N spread over days is a\n")
b.WriteString("chronic fault. Near-identical lines are already folded together.\n")
for _, g := range logs.Groups {
tool := ""
if g.Tool != "" {
tool = "tool " + g.Tool + ": "
}
fmt.Fprintf(&b, "- x%d [%s .. %s] %s%s\n", g.Count,
fmt.Fprintf(&b, "- x%d %s [%s .. %s] %s%s\n", g.Count, g.Level,
g.First.Format(time.RFC3339), g.Last.Format(time.RFC3339), tool, g.Sample)
}
}
Expand Down Expand Up @@ -421,11 +422,13 @@ For each recurring failure: what is actually going wrong and the concrete fix
Skip tools whose failures look incidental rather than systematic.

### Failures that never reached a session
Recurring failures from LOG FAILURES below, which no saved session recorded - crashes,
startup and background errors. For each: what is actually going wrong and the concrete
fix. Read the count with the time span: a high count over seconds is a retry loop that
never succeeded, the same count spread over days is a chronic fault. Skip anything that
looks incidental. If there are no log failures, say so in one line.
Recurring problems from LOG WARNINGS AND ERRORS below that no saved session recorded -
crashes, startup and background problems. Lines naming a tool repeat the failed tool
calls above, so do not count them again. A warn is a recoverable problem, so report one
only when it costs the user something. For each: what is actually going wrong and the
concrete fix. Read the count with the time span: a high count over seconds is a retry
loop that never succeeded, the same count spread over days is a chronic fault. Skip
anything that looks incidental. If there is nothing to report, say so in one line.

PERSISTENT MEMORY below, when present, is what the agent has already learned about
this user. Use it as context so you do not suggest what they already do; never
Expand Down Expand Up @@ -516,11 +519,11 @@ func renderReport(meta reportMeta, failures []toolFailure, tools []telemetry.Too
}

if len(logs.Groups) > 0 {
b.WriteString("## Log failures\n\n")
b.WriteString("| Count | First | Last | Tool | Message | Trace |\n")
b.WriteString("|-------|-------|------|------|---------|-------|\n")
b.WriteString("## Log warnings and errors\n\n")
b.WriteString("| Count | Level | First | Last | Tool | Message | Trace |\n")
b.WriteString("|-------|-------|-------|------|------|---------|-------|\n")
for _, g := range logs.Groups {
fmt.Fprintf(&b, "| %d | %s | %s | %s | %s | %s |\n", g.Count,
fmt.Fprintf(&b, "| %d | %s | %s | %s | %s | %s | %s |\n", g.Count, g.Level,
g.First.Format(time.RFC3339), g.Last.Format(time.RFC3339), g.Tool,
strings.ReplaceAll(g.Sample, "|", "\\|"), g.TraceID)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/insights/insights_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var logNoise = regexp.MustCompile(`(/[\w.\-/]+)|(\b[0-9a-f]{8,}\b)`)
// same count spread over days is a chronic fault.
type logGroup struct {
Template string
Level string
Count int
First time.Time
Last time.Time
Expand Down Expand Up @@ -172,7 +173,7 @@ func addLogRecord(groups map[string]*logGroup, rec logRecord, when time.Time) {
if len(groups) >= maxLogTemplates {
return
}
g = &logGroup{Template: key, First: when, Last: when, Sample: oneLine(raw, maxLogSampleChars), Tool: rec.Tool}
g = &logGroup{Template: key, Level: rec.Level, First: when, Last: when, Sample: oneLine(raw, maxLogSampleChars), Tool: rec.Tool}
groups[key] = g
}
if g.TraceID == "" {
Expand Down
4 changes: 2 additions & 2 deletions internal/insights/insights_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,14 @@ func TestLogFailureWithoutConversationReachesDigest(t *testing.T) {
}

prompt := buildDigest(nil, nil, nil, "", digest)
for _, want := range []string{"LOG FAILURES", "x400", "failed to start gateway container", "port 8080 already in use"} {
for _, want := range []string{"LOG WARNINGS AND ERRORS", "x400", "failed to start gateway container", "port 8080 already in use"} {
if !strings.Contains(prompt, want) {
t.Errorf("digest missing %q:\n%s", want, prompt)
}
}

report := renderReport(reportMeta{Generated: now, LogRecords: digest.Scanned, LogGroups: len(digest.Groups)}, nil, nil, digest, "x")
for _, want := range []string{"log_records: 400", "log_groups: 1", "## Log failures", "| 400 |"} {
for _, want := range []string{"log_records: 400", "log_groups: 1", "## Log warnings and errors", "| 400 | error |"} {
if !strings.Contains(report, want) {
t.Errorf("report missing %q:\n%s", want, report)
}
Expand Down
12 changes: 6 additions & 6 deletions internal/insights/insights_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ func TestLogSectionSurvivesManySessions(t *testing.T) {
if len(digest) > maxDigestChars+3 {
t.Errorf("digest not bounded: %d chars", len(digest))
}
for _, want := range []string{"LOG FAILURES", "x400", "failed to start gateway container"} {
for _, want := range []string{"LOG WARNINGS AND ERRORS", "x400", "failed to start gateway container"} {
if !strings.Contains(digest, want) {
t.Errorf("a busy session store must not crowd out %q:\n...%s", want, digest[max(0, len(digest)-400):])
}
Expand All @@ -459,11 +459,11 @@ func TestPromptCoversEveryDigestSection(t *testing.T) {
)

headings := map[string]string{
"PERSISTENT MEMORY": "PERSISTENT MEMORY",
"SESSIONS": "sessions",
"TOOL CALL TOTALS": "Tool calls that keep failing",
"FAILED TOOL CALLS": "Tool calls that keep failing",
"LOG FAILURES": "LOG FAILURES",
"PERSISTENT MEMORY": "PERSISTENT MEMORY",
"SESSIONS": "sessions",
"TOOL CALL TOTALS": "Tool calls that keep failing",
"FAILED TOOL CALLS": "Tool calls that keep failing",
"LOG WARNINGS AND ERRORS": "LOG WARNINGS AND ERRORS",
}
for section, instruction := range headings {
if !strings.Contains(digest, section) {
Expand Down
23 changes: 10 additions & 13 deletions internal/platform/container/docker_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os/exec"
"strings"
"sync/atomic"

logger "github.com/inference-gateway/cli/internal/platform/logger"
)
Expand All @@ -13,7 +14,7 @@ import (
type DockerRuntime struct {
sessionID string
networkName string
networkCreated bool
networkCreated atomic.Bool
}

// NewDockerRuntime creates a new Docker runtime manager
Expand All @@ -30,16 +31,12 @@ func (dr *DockerRuntime) GetNetworkName() string {
}

// EnsureNetwork creates the shared Docker network if it doesn't exist. The
// network is reused across sessions, so at most one ever exists. If creation
// fails because the IPAM address pools are exhausted (leaked networks from
// prior sessions), it prunes those and retries once.
// network is reused across sessions and any of them may remove it, so every
// call checks again. If creation fails because the IPAM address pools are
// exhausted (leaked networks from prior sessions), it prunes those and retries once.
func (dr *DockerRuntime) EnsureNetwork(ctx context.Context) error {
if dr.networkCreated {
return nil
}

if err := exec.CommandContext(ctx, "docker", "network", "inspect", dr.networkName).Run(); err == nil {
dr.networkCreated = true
dr.networkCreated.Store(true)
return nil
}

Expand All @@ -54,7 +51,7 @@ func (dr *DockerRuntime) EnsureNetwork(ctx context.Context) error {
}
}

dr.networkCreated = true
dr.networkCreated.Store(true)
logger.Info("docker network ready", "session", dr.sessionID, "network", dr.networkName)
return nil
}
Expand All @@ -79,20 +76,20 @@ func (dr *DockerRuntime) createNetwork(ctx context.Context) error {
// (keeping networkCreated set so a later call retries once the network frees
// up). It is never an error to fail here - shutdown must not block.
func (dr *DockerRuntime) CleanupNetwork(ctx context.Context) error {
if !dr.networkCreated {
if !dr.networkCreated.Load() {
return nil
}

output, err := exec.CommandContext(ctx, "docker", "network", "rm", dr.networkName).CombinedOutput()
if err == nil {
dr.networkCreated = false
dr.networkCreated.Store(false)
logger.Info("docker network removed successfully", "network", dr.networkName)
return nil
}

switch gone, inUse := interpretNetworkRm(string(output)); {
case gone:
dr.networkCreated = false
dr.networkCreated.Store(false)
case inUse:
logger.Debug("docker network still in use by another session; leaving in place", "network", dr.networkName)
default:
Expand Down
23 changes: 10 additions & 13 deletions internal/platform/container/podman_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os/exec"
"strings"
"sync/atomic"

logger "github.com/inference-gateway/cli/internal/platform/logger"
)
Expand All @@ -13,7 +14,7 @@ import (
type PodmanRuntime struct {
sessionID string
networkName string
networkCreated bool
networkCreated atomic.Bool
}

// NewPodmanRuntime creates a new Podman runtime manager
Expand All @@ -30,16 +31,12 @@ func (pr *PodmanRuntime) GetNetworkName() string {
}

// EnsureNetwork creates the shared Podman network if it doesn't exist. The
// network is reused across sessions, so at most one ever exists. If creation
// fails because the IPAM address pools are exhausted (leaked networks from
// prior sessions), it prunes those and retries once.
// network is reused across sessions and any of them may remove it, so every
// call checks again. If creation fails because the IPAM address pools are
// exhausted (leaked networks from prior sessions), it prunes those and retries once.
func (pr *PodmanRuntime) EnsureNetwork(ctx context.Context) error {
if pr.networkCreated {
return nil
}

if err := exec.CommandContext(ctx, "podman", "network", "inspect", pr.networkName).Run(); err == nil {
pr.networkCreated = true
pr.networkCreated.Store(true)
return nil
}

Expand All @@ -54,7 +51,7 @@ func (pr *PodmanRuntime) EnsureNetwork(ctx context.Context) error {
}
}

pr.networkCreated = true
pr.networkCreated.Store(true)
logger.Info("podman network ready", "session", pr.sessionID, "network", pr.networkName)
return nil
}
Expand All @@ -79,20 +76,20 @@ func (pr *PodmanRuntime) createNetwork(ctx context.Context) error {
// (keeping networkCreated set so a later call retries once the network frees
// up). It is never an error to fail here - shutdown must not block.
func (pr *PodmanRuntime) CleanupNetwork(ctx context.Context) error {
if !pr.networkCreated {
if !pr.networkCreated.Load() {
return nil
}

output, err := exec.CommandContext(ctx, "podman", "network", "rm", pr.networkName).CombinedOutput()
if err == nil {
pr.networkCreated = false
pr.networkCreated.Store(false)
logger.Info("podman network removed successfully", "network", pr.networkName)
return nil
}

switch gone, inUse := interpretNetworkRm(string(output)); {
case gone:
pr.networkCreated = false
pr.networkCreated.Store(false)
case inUse:
logger.Debug("podman network still in use by another session; leaving in place", "network", pr.networkName)
default:
Expand Down
Loading
Loading