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
57 changes: 30 additions & 27 deletions docs/agent/attn-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,35 @@ same object the agent already parsed:
`engine/cmd/jev-attention` remains as a thin shell over the same runner package
(`internal/attn/runner`) while existing scripts and the installed skill catch up.

The adapter supports TypeSafe's endpoint and OpenRouter's Jev Decisions API.
For OpenRouter, use `POST https://openrouter.ai/api/alpha/decisions` with the
`typesafe/jev-1.13` model. Both endpoints accept the same structured `state`,
model, and typed `questions`; responses contain typed `answers`. The adapter
uses two `noul` questions (`task_relevant`, `needs_human`) and a `choice`
question for the authorized fixed options. See the [OpenRouter Jev guide](https://openrouter.ai/blog/insights/what-is-jev/)
The adapter speaks one wire contract: a structured `state`, `model` and typed
`questions` request, answered with typed `answers`. TypeSafe's endpoint and
OpenRouter's Jev Decisions API both implement it; any other endpoint that
implements the same shape works too. Endpoint, model and key are independent
fields — the `openrouter` and `typesafe` presets only fill the first two while
you configure, and are never consulted at runtime. The adapter uses two `noul`
questions (`task_relevant`, `needs_human`) and a `choice` question for the
authorized fixed options. See the [OpenRouter Jev guide](https://openrouter.ai/blog/insights/what-is-jev/)
and [TypeSafe API reference](https://docs.typesafe.ai/api/) for the upstream
wire contract.

## Turning it on

`oberth attn enable` is the one step, in this order: choose a provider, read the
key, prove the key answers, then write the owner-only file.
`oberth attn enable` is the one step, in this order: give an endpoint and model
(directly, or through a preset that fills both), read the key, prove the key
answers, then write the owner-only file.

```sh
# a script or CI job: the key never appears in an argument list
printf '%s' "$KEY" | oberth attn enable --provider openrouter --key-stdin
printf '%s' "$KEY" | oberth attn enable --preset openrouter --key-stdin

# read it from an existing variable
oberth attn enable --provider typesafe --key-env TYPESAFE_API_KEY
oberth attn enable --preset typesafe --key-env ATTN_KEY

# any compatible endpoint: no preset, explicit fields
oberth attn enable --endpoint https://assess.example/decisions --model team/model --key-env ATTN_KEY

# a person at a terminal: hidden input
oberth attn enable --provider openrouter
oberth attn enable --preset openrouter
```

The proof is one synthetic, redacted assessment — the same request shape a real
Expand Down Expand Up @@ -126,12 +132,10 @@ assessment handoffs, and `stopped` when the agent cancels the process. It polls
`oberth status --json --no-mark`, so the daemon and core CLI remain model free
and no hook or MCP registration is needed.

When `OPENROUTER_API_KEY` is present, the adapter selects
`https://openrouter.ai/api/alpha/decisions` and `typesafe/jev-1.13`
automatically. `OPENROUTER_ENDPOINT` and `OPENROUTER_MODEL` can override
those defaults. `TYPESAFE_API_KEY`, `TYPESAFE_ENDPOINT`, and `TYPESAFE_MODEL`
remain available for the native TypeSafe endpoint. Never put either provider's
key in a repository file or a task-context JSON file.
For CI and one-off runs the same three fields come from the environment:
`BERTH_ATTN_ENDPOINT`, `BERTH_ATTN_MODEL` and `BERTH_ATTN_KEY`. They override
the stored fields for that process only. Never put a key in a repository file or
a task-context JSON file.

### An environment key is a secret, not consent

Expand All @@ -143,12 +147,11 @@ to be sent to a provider. Only two things turn the assessment on:
| `"enabled": true` in the owner-only `attn.json` | every process that shares this `BERTH_HOME` |
| `BERTH_ATTN=1` (`true`, `yes` or `on`) | this process only, for CI and one-off runs |

Everything else only supplies settings: `OPENROUTER_API_KEY` / `TYPESAFE_API_KEY`
and their endpoint/model variables pick the provider and the secret. An
`attn.json` that says `"enabled": false` is a decision the person already made,
so an environment key does not override it. One resolution function decides all
of this, and the setup command's `--endpoint`, `--model` and `--timeout` change
the field they name without granting consent.
Everything else only supplies settings: `BERTH_ATTN_ENDPOINT`,
`BERTH_ATTN_MODEL` and `BERTH_ATTN_KEY` change the field they name and never
grant consent. An `attn.json` that says `"enabled": false` is a decision the
person already made, so an environment key does not override it. One resolution
function decides all of this.

### Fixed reasons instead of provider text

Expand All @@ -169,7 +172,7 @@ at `~/.option-berth/attn.json` (or `$BERTH_HOME/attn.json` in an isolated run):
{
"schema": "oberth.attn-settings/v1",
"enabled": true,
"provider": "openrouter",
"preset": "openrouter",
"endpoint": "https://openrouter.ai/api/alpha/decisions",
"model": "typesafe/jev-1.13",
"api_key": "…",
Expand All @@ -179,9 +182,9 @@ at `~/.option-berth/attn.json` (or `$BERTH_HOME/attn.json` in an isolated run):

The adapter reads this file automatically, so an agent does not need to
re-export a shell variable for every task. This file is the only persistent
consent: an explicit `OPENROUTER_API_KEY` or `TYPESAFE_API_KEY` supplies the key
and selects the provider but does not enable the model, and `enabled: false`
keeps it off even when a key is present in the environment. The key never
consent: an environment key supplies the secret but does not enable the model,
and `enabled: false` keeps it off even when a key is present in the environment.
The key never
appears in attention artifacts, quality telemetry, or daemon output.

For one-off use, `--task` and `--authorize inspect_logs,restart_service` can
Expand Down
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,12 @@ will not guess an authorization set on the agent's behalf. The adapter sends onl
summary to the selected provider and does not read logs or start and stop services. The runner
outputs `clear`, `continue`, `human_required`, `unavailable` or `stale`; on success it atomically
writes the validated `assessment` back to the same file and re-reads the state before returning. When
there is no usable environment variable or GUI-local model configuration, or on network failure,
there is no usable environment endpoint, model or key, or on network failure,
invalid response, expired file or changed state revision, the fact file is left unchanged. The
`~/.option-berth/attn.json` written by `oberth attn enable` (`$BERTH_HOME/attn.json` for isolated
runs) is read automatically by `oberth attn` and is the only persistent consent: `enabled: true`
turns the model on, `enabled: false` keeps it off even when a key is present. An explicit
`OPENROUTER_API_KEY` / `TYPESAFE_API_KEY` supplies the key and selects the provider but does not
`BERTH_ATTN_ENDPOINT` / `BERTH_ATTN_MODEL` / `BERTH_ATTN_KEY` supplies those fields but does not
enable the model; `BERTH_ATTN=1` enables it for one process. Each unavailable answer carries one fixed
reason (`no_key`, `disabled`, `invalid_config`, `unauthorized`, `rate_limited`, `timeout`, `network`,
`provider_error`, `bad_response`, `stale`) rather than a provider's error text. `--auto`, `--session` and
Expand Down
6 changes: 3 additions & 3 deletions docs/cli.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,11 @@ oberth attn assess --project "$PWD" \
猜一个授权集。适配器只向选定的提供商发送脱敏的事实摘要,
不会读取日志或起停服务。runner 输出 `clear`、`continue`、`human_required`、`unavailable` 或
`stale`;成功时把校验后的 `assessment` 原子写回同一文件,并在返回前复读状态。
没有可用的环境变量或 本地模型配置、网络失败、响应无效、文件过期或状态修订变化时,事实文件保持不变。
没有可用的环境 endpoint、model 或 key、网络失败、响应无效、文件过期或状态修订变化时,事实文件保持不变。
`oberth attn enable` 写入的 `~/.option-berth/attn.json`(隔离运行时为 `$BERTH_HOME/attn.json`)由
`oberth attn` 自动读取,并且是唯一的持久同意:`enabled: true` 开启,`enabled: false` 即使环境
里有密钥也保持关闭。显式 `OPENROUTER_API_KEY` / `TYPESAFE_API_KEY` 只提供密钥并选定 provider,不
开启评估;`BERTH_ATTN=1` 只为当前进程开启。每次不可用都带固定原因码(`no_key`、`disabled`、
里有密钥也保持关闭。显式 `BERTH_ATTN_ENDPOINT` / `BERTH_ATTN_MODEL` / `BERTH_ATTN_KEY` 只提供这三个
字段,不开启评估;`BERTH_ATTN=1` 只为当前进程开启。每次不可用都带固定原因码(`no_key`、`disabled`、
`invalid_config`、`unauthorized`、`rate_limited`、`timeout`、`network`、`provider_error`、
`bad_response`、`stale`),而不是 provider 的错误原文。
`--auto`、`--session` 和 `--subscribe` 必须显式提供 `authorized_options`;artifact 的 `options` 只是候选集,
Expand Down
62 changes: 32 additions & 30 deletions engine/cmd/jev-attention/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ func TestLoadContextFileAndFlagOverrides(t *testing.T) {
// permission. These tables pin the rule the old default* helpers encoded by
// accident — environment keys used to switch model on by their mere presence.
func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) {
enabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Provider: "openrouter", APIKey: "settings-key"}
disabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: false, Provider: "openrouter", APIKey: "settings-key"}
const endpoint, modelName = "https://assess.example/decisions", "test/model"
enabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Preset: "openrouter", Endpoint: endpoint, Model: modelName, APIKey: "settings-key"}
disabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: false, Preset: "openrouter", Endpoint: endpoint, Model: modelName, APIKey: "settings-key"}
cases := []struct {
name string
input model.ResolveInput
Expand All @@ -62,7 +63,7 @@ func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) {
}{
{
name: "environment key alone is not consent",
input: model.ResolveInput{OpenRouterKey: "env-key"},
input: model.ResolveInput{Key: "env-key"},
wantEnabled: false,
wantReason: model.ReasonDisabled,
},
Expand All @@ -74,37 +75,37 @@ func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) {
},
{
name: "settings enabled falls back to the environment key",
input: model.ResolveInput{Settings: enabled, SettingsPresent: true, OpenRouterKey: "env-key"},
input: model.ResolveInput{Settings: enabled, SettingsPresent: true, Key: "env-key"},
wantEnabled: true,
wantKey: "env-key",
},
{
name: "explicitly disabled settings are not overridden by a key",
input: model.ResolveInput{Settings: disabled, SettingsPresent: true, OpenRouterKey: "env-key"},
input: model.ResolveInput{Settings: disabled, SettingsPresent: true, Key: "env-key"},
wantEnabled: false,
wantReason: model.ReasonDisabled,
},
{
name: "one-shot BERTH_ATTN is consent without a settings file",
input: model.ResolveInput{OneShot: "1", TypeSafeKey: "env-key"},
input: model.ResolveInput{OneShot: "1", Endpoint: endpoint, Model: modelName, Key: "env-key"},
wantEnabled: true,
wantKey: "env-key",
},
{
name: "BERTH_ATTN=0 is not consent",
input: model.ResolveInput{OneShot: "0", TypeSafeKey: "env-key"},
input: model.ResolveInput{OneShot: "0", Endpoint: endpoint, Model: modelName, Key: "env-key"},
wantEnabled: false,
wantReason: model.ReasonDisabled,
},
{
name: "enabled settings with no key anywhere reports no_key",
input: model.ResolveInput{Settings: model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Provider: "typesafe"}, SettingsPresent: true},
input: model.ResolveInput{Settings: model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Endpoint: endpoint, Model: modelName}, SettingsPresent: true},
wantEnabled: true,
wantReason: model.ReasonNoKey,
},
{
name: "a malformed settings file is a closed door, not a fallthrough",
input: model.ResolveInput{SettingsPresent: true, SettingsErr: errors.New("bad json"), OpenRouterKey: "env-key"},
input: model.ResolveInput{SettingsPresent: true, SettingsErr: errors.New("bad json"), Key: "env-key"},
wantEnabled: false,
wantReason: model.ReasonInvalidConfig,
},
Expand All @@ -129,23 +130,24 @@ func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) {
}

func TestResolvedClientRefusesWithoutConsent(t *testing.T) {
withoutConsent := model.Resolved{Key: "k", Endpoint: model.DefaultEndpoint, Reason: model.ReasonDisabled}
withoutConsent := model.Resolved{Key: "k", Endpoint: "https://assess.example/decisions", Model: "test/model", Reason: model.ReasonDisabled}
if _, err := withoutConsent.Client(); err == nil {
t.Fatal("a disabled configuration produced a client")
}
enabled := model.Resolved{Enabled: true, Key: "k", Endpoint: model.DefaultEndpoint, Model: model.DefaultModel, Timeout: time.Second}
enabled := model.Resolved{Enabled: true, Key: "k", Endpoint: "https://assess.example/decisions", Model: "test/model", Timeout: time.Second}
client, err := enabled.Client()
if err != nil {
t.Fatalf("enabled configuration: %v", err)
}
if client.APIKey != "k" || client.Endpoint != model.DefaultEndpoint {
if client.APIKey != "k" || client.Endpoint != "https://assess.example/decisions" {
t.Fatalf("client = %+v", client)
}
}

func TestRedactedStateNeverCarriesTheKey(t *testing.T) {
resolved := model.Resolve(model.ResolveInput{Settings: model.LocalConfig{
Schema: model.LocalConfigSchema, Enabled: true, Provider: "openrouter", APIKey: "secret-key",
Schema: model.LocalConfigSchema, Enabled: true, Preset: "openrouter",
Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "secret-key",
}, SettingsPresent: true})
state := resolved.RedactedState()
encoded, err := json.Marshal(state)
Expand All @@ -160,24 +162,23 @@ func TestRedactedStateNeverCarriesTheKey(t *testing.T) {
}
}

func TestLocalJevSettingsSelectOpenRouterDefaults(t *testing.T) {
func TestLocalSettingsSupplyEndpointModelAndKey(t *testing.T) {
home := t.TempDir()
t.Setenv("BERTH_HOME", home)
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("TYPESAFE_API_KEY", "")
t.Setenv("BERTH_ATTN_KEY", "")
if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{
"schema":"oberth.attn-settings/v1",
"enabled":true,
"provider":"openrouter",
"preset":"openrouter",
"endpoint":"https://local.example/decisions",
"model":"local/jev",
"model":"local/model",
"api_key":"local-key",
"timeout_ms":5000
}`), 0o600); err != nil {
t.Fatal(err)
}
got := resolveConsent("", false)
if !got.Enabled || got.Endpoint != "https://local.example/decisions" || got.Model != "local/jev" {
if !got.Enabled || got.Preset != "openrouter" || got.Endpoint != "https://local.example/decisions" || got.Model != "local/model" {
t.Fatalf("resolved = %+v", got)
}
if got.Key != "local-key" || got.Timeout != 5*time.Second {
Expand All @@ -188,28 +189,29 @@ func TestLocalJevSettingsSelectOpenRouterDefaults(t *testing.T) {
}
}

func TestEnvironmentKeySelectsTheProviderButNotConsent(t *testing.T) {
func TestEnvironmentFieldsSupplyEndpointAndModelButNotConsent(t *testing.T) {
home := t.TempDir()
t.Setenv("BERTH_HOME", home)
t.Setenv("OPENROUTER_API_KEY", "environment-key")
t.Setenv("OPENROUTER_ENDPOINT", "https://env.example/decisions")
t.Setenv("OPENROUTER_MODEL", "env/jev")
t.Setenv("TYPESAFE_API_KEY", "")
t.Setenv("BERTH_ATTN_KEY", "environment-key")
t.Setenv("BERTH_ATTN_ENDPOINT", "https://env.example/decisions")
t.Setenv("BERTH_ATTN_MODEL", "env/model")
got := resolveConsent("", false)
if got.Enabled {
t.Fatalf("an environment key enabled the assessment: %+v", got)
}
if got.Provider != model.ProviderOpenRouter || got.Endpoint != "https://env.example/decisions" || got.Model != "env/jev" {
t.Fatalf("provider selection = %+v", got)
if got.Endpoint != "https://env.example/decisions" || got.Model != "env/model" {
t.Fatalf("environment fields = %+v", got)
}
if got.Key != "" {
t.Fatalf("a disabled configuration leaked the key: %q", got.Key)
}
}

func TestExplicitAPIKeyFlagWinsAndCanDisable(t *testing.T) {
home := t.TempDir()
t.Setenv("BERTH_HOME", home)
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("TYPESAFE_API_KEY", "")
if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{"schema":"oberth.attn-settings/v1","enabled":true,"provider":"openrouter","api_key":"local-key"}`), 0o600); err != nil {
t.Setenv("BERTH_ATTN_KEY", "")
if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{"schema":"oberth.attn-settings/v1","enabled":true,"preset":"openrouter","endpoint":"https://assess.example/decisions","model":"test/model","api_key":"local-key"}`), 0o600); err != nil {
t.Fatal(err)
}
if got := resolveConsent("", false); got.Key != "local-key" {
Expand Down Expand Up @@ -244,7 +246,7 @@ func TestRunPreflightWritesFactRequestWhenModelUnavailable(t *testing.T) {
}
t.Setenv("BERTH_HOME", home)
// No consent: the request must still be written, and no provider contacted.
disabled := model.Resolved{Endpoint: model.DefaultEndpoint, Model: model.DefaultModel, Timeout: time.Second, Reason: model.ReasonDisabled}
disabled := model.Resolved{Endpoint: "https://assess.example/decisions", Model: "test/model", Timeout: time.Second, Reason: model.ReasonDisabled}
if err := runPreflight(statusPath, "", "restart_service", "api", "", "ask before restart", false, disabled, model.TaskContext{}); err != nil {
t.Fatal(err)
}
Expand Down
31 changes: 12 additions & 19 deletions engine/internal/attn/model/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
Expand All @@ -27,14 +26,10 @@ import (
)

const (
DefaultEndpoint = "https://api.typesafe.ai/v1/systemone"
DefaultModel = "jev-latest"
DefaultOpenRouterEndpoint = "https://openrouter.ai/api/alpha/decisions"
DefaultOpenRouterModel = "typesafe/jev-1.13"
DefaultTimeout = 10 * time.Second
maxTaskBytes = 2000
maxReasonBytes = 256
maxServiceBytes = 128
DefaultTimeout = 10 * time.Second
maxTaskBytes = 2000
maxReasonBytes = 256
maxServiceBytes = 128
)

// ErrUnavailable means that no assessment was made. Callers should leave the
Expand Down Expand Up @@ -98,8 +93,8 @@ func (t TaskContext) validate(options []attn.Option) ([]string, error) {
return FilterAuthorizedOptions(options, t.AuthorizedOptions)
}

// Client calls the official TypeSafe System One endpoint. HTTPClient is
// injectable for tests; nil uses http.DefaultClient.
// Client calls one configured endpoint. HTTPClient is injectable for tests;
// nil uses http.DefaultClient.
type Client struct {
Endpoint string
APIKey string
Expand All @@ -113,15 +108,13 @@ func (c Client) normalized() (Client, error) {
if c.APIKey == "" {
return Client{}, WithReason(ReasonNoKey, ErrUnavailable)
}
if strings.TrimSpace(c.Endpoint) == "" {
c.Endpoint = DefaultEndpoint
c.Endpoint = strings.TrimSpace(c.Endpoint)
c.Model = strings.TrimSpace(c.Model)
if c.Endpoint == "" || c.Model == "" {
return Client{}, WithReason(ReasonInvalidConfig, ErrUnavailable)
}
u, err := url.Parse(c.Endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" {
return Client{}, fmt.Errorf("attn: endpoint must be an HTTPS URL")
}
if strings.TrimSpace(c.Model) == "" {
c.Model = DefaultModel
if err := validateEndpoint(c.Endpoint); err != nil {
return Client{}, WithReason(ReasonInvalidConfig, fmt.Errorf("attn: %w", err))
}
if c.Timeout <= 0 {
c.Timeout = DefaultTimeout
Expand Down
Loading
Loading