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
34 changes: 34 additions & 0 deletions cmd/atryum/commands_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"log/slog"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -32,6 +33,39 @@ func TestRunUsageMentionsConfigFlag(t *testing.T) {
}
}

func TestParseLogLevel(t *testing.T) {
tests := []struct {
name string
input string
want slog.Level
}{
{name: "empty", input: "", want: slog.LevelInfo},
{name: "info", input: "info", want: slog.LevelInfo},
{name: "debug", input: "debug", want: slog.LevelDebug},
{name: "warn", input: "warn", want: slog.LevelWarn},
{name: "warning", input: "warning", want: slog.LevelWarn},
{name: "error", input: "error", want: slog.LevelError},
{name: "trim and case", input: " DEBUG ", want: slog.LevelDebug},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseLogLevel(tt.input)
if err != nil {
t.Fatalf("parseLogLevel(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseLogLevel(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

func TestParseLogLevelRejectsInvalidLevel(t *testing.T) {
if _, err := parseLogLevel("verbose"); err == nil {
t.Fatal("expected invalid log level error")
}
}

func TestBuildDemoConfigIncludesCalcUpstream(t *testing.T) {
cfg := buildDemoConfig("/tmp/atryum.db")
if !strings.Contains(cfg, "[[upstreams]]") {
Expand Down
30 changes: 30 additions & 0 deletions cmd/atryum/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"fmt"
"io"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -80,6 +82,9 @@ func runServer(args []string) error {
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if err := configureLogging(cfg.Server.LogLevel); err != nil {
return err
}
backendClient, err := backendclient.NewClient(cfg.Backend)
if err != nil {
return fmt.Errorf("backend config: %w", err)
Expand Down Expand Up @@ -236,6 +241,7 @@ func runServer(args []string) error {
if backendClient != nil {
service.SetInvocationSummarizer(&summaryAdapter{client: backendClient})
}
service.SetSessionStore(store.NewExternalSessionRepoWithDialect(db, dialect))
serverAdmin := api.NewServerAdminService(serverRepo, oauthRepo, client, 5*time.Second, cfg.Server.PublicBaseURL)
if *initServers {
if err := initEnabledServerStatuses(context.Background(), serverRepo, serverAdmin); err != nil {
Expand Down Expand Up @@ -647,6 +653,30 @@ func truthyEnv(name string) bool {
return value == "1" || value == "true" || value == "TRUE" || value == "yes" || value == "YES"
}

func configureLogging(logLevel string) error {
level, err := parseLogLevel(logLevel)
if err != nil {
return err
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
return nil
}

func parseLogLevel(logLevel string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(logLevel)) {
case "", "info":
return slog.LevelInfo, nil
case "debug":
return slog.LevelDebug, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("invalid server.log_level %q", logLevel)
}
}

func emptyDefault(value, fallback string) string {
if value == "" {
return fallback
Expand Down
37 changes: 19 additions & 18 deletions examples/amp-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,25 @@ To remove the global plugin later:
| `ATRYUM_TOKEN_REFRESH_SKEW_MS` | `60000` | refresh command cache skew before token expiry |
| `ATRYUM_TOKEN_COMMAND_TIMEOUT_MS` | `10000` | timeout for the token command subprocess |
| `ATRYUM_STATE_DIR` | `~/.atryum/amp-plugin-state` | directory for the on-disk token cache (`token-cache.json`, mode 0600) |
| `ATRYUM_CHAT_MESSAGES_LIMIT` | `100` | recent Amp thread messages sent as LLM-as-judge context |
| `ATRYUM_AMP_THREADS_DIR` | `~/.local/share/amp/threads` | Amp thread JSON directory |
| `ATRYUM_AMP_SESSION_FILE` | `~/.local/share/amp/session.json` | Amp session state file used to identify the active thread |

## LLM-as-judge chat context

Before each tool call, the plugin builds compact recent context and sends it
to Atryum as `chat_context` and `chat_context_messages` (plus the deprecated
`context` alias for compatibility). It first tries the live plugin context
when available. If Amp has persisted the active thread under
`ATRYUM_AMP_THREADS_DIR`, the plugin includes that archived chat text.
For active sessions where Amp has not written a thread JSON file yet, it uses
the active thread ID from `ATRYUM_AMP_SESSION_FILE`, reads Amp's current
thread log, and includes fresh message/tool activity plus the tool calls and
results observed by the plugin.

Set `ATRYUM_CHAT_MESSAGES_LIMIT` to change how many recent messages are sent.
Set it to `0` to disable Amp chat context.
| `ATRYUM_AMP_SESSION_FILE` | `~/.local/share/amp/session.json` | Amp session state file used to label the Atryum session with Amp's thread id |

## LLM-as-judge session context

The plugin does **not** send a free-form chat/context blob to Atryum. The
harness is trusted to report _which_ session a tool call belongs to, but a
runaway agent must not be able to hand the judge arbitrary text to poison it.

Instead, on the first tool call the plugin mints an Atryum session via
`POST /api/v1/external/sessions` (passing `harness` and, for cross-referencing
only, Amp's own thread id as `client_session_id`). It caches the returned
`session_id` for the lifetime of the process and echoes it on every
`POST /api/v1/external/invocations`. Atryum then reconstructs the judge's
context from the prior tool calls it recorded for that session — trusting tool
outputs more than tool inputs, and ignoring agent chat entirely.

If session creation fails, the plugin falls back to submitting without a
`session_id` (tool calls are still gated, just without prior-call context)
rather than blocking the agent.

## Tagging invocations to an Agent Record

Expand Down
Loading
Loading