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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ CLI / TUI / React / Windows Desktop / CI
| 领域 | 当前能力 |
|---|---|
| Agent 运行时 | Mission/Run、可恢复 Supervisor、严格生命周期、检查点、取消、重试、预算和执行租约 |
| 模型与上下文 | Mock 与 Anthropic-compatible Provider、模型路由、资格校验、流式响应、上下文压缩、结构化记忆 |
| 模型与上下文 | Mock、Anthropic-compatible 与 OpenAI-compatible Provider、模型路由、资格校验、流式响应、上下文压缩、结构化记忆 |
| 计划与协作 | Plan/Delivery、工作项、备注、最多两个核心 child、1/2/4/6 档只读 Fan-out、共享预算与取消扇出 |
| 工具与权限 | Tool Gateway、JSON Schema 校验、Policy、Scope、人工审批、四档宿主权限、受控固定命令提案 |
| 代码工作流 | 系统目录选择与 Workspace 导入、工作区浏览、仓库状态、提交历史、Diff 审阅、文件编辑提案、验证计划、Code Journey 与 Handoff |
Expand Down Expand Up @@ -102,6 +102,11 @@ go run ./cmd/cyberagent tui

默认配置使用确定性的 Mock Provider,不需要 API key。接入外部模型前,请先阅读 [Provider 与模型配置](docs/usage.md#model-and-provider-commands);凭证应进入系统凭证存储或进程环境,不能提交到仓库。

OpenAI-compatible 连接使用独立的 `CYBERAGENT_OPENAI_API_KEY`、
`CYBERAGENT_OPENAI_BASE_URL` 与 `CYBERAGENT_OPENAI_MODEL` 环境变量;后两项默认分别为
`https://api.openai.com` 和 `gpt-4.1-mini`。仓库内的 `configs/models.yaml`
只是无秘密示例,不会作为运行时配置源。

### Windows Desktop 预览

```powershell
Expand Down
20 changes: 20 additions & 0 deletions configs/models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,23 @@ providers:
mock:
type: mock
secrets: none

# Documentation-only example. This repository file is not loaded as runtime
# Provider configuration and must never contain credentials or private headers.
# Configure the trusted process environment instead:
# CYBERAGENT_OPENAI_API_KEY=<secret>
# CYBERAGENT_OPENAI_BASE_URL=https://api.openai.com
# CYBERAGENT_OPENAI_MODEL=gpt-4.1-mini
# The API key may alternatively be stored under the `openai` entry in the
# Go-owned operating-system credential store.
openai_compatible_example:
provider_name: openai
type: openai_compatible
transport: openai_chat_completions
default_base_url: https://api.openai.com
default_model: gpt-4.1-mini
timeout_seconds: 60
request_headers: [Authorization, Content-Type, Accept]
compatibility_profile: strict_chat_completions
runtime_loaded: false
secrets: environment_or_system_store_only
21 changes: 20 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,26 @@ The initial product remains conservative: real public-network attack automation

The LLM router remains independent from orchestration. Run snapshots record the selected provider/model route without persisting API keys. Providers normalize HTTP, network, protocol, and cancellation errors into typed outcomes; only RunSupervisor decides whether a side-effect-free model request may be retried. Legacy unbound Session chat receives typed errors through Router but does not gain an implicit retry loop.

Environment adapters currently expose `mimo` and `deepseek` as separate names over the shared Anthropic-compatible transport. Each adapter reads only its own API-key/base-URL/model namespace, and the Provider object remains inside the Go control plane; credentials never enter Run configuration or event payloads.
Environment adapters expose `mimo`, `deepseek`, and `anthropic` over the shared
Anthropic-compatible transport, plus the canonical `openai` adapter over OpenAI Chat
Completions. Each adapter reads only its dedicated API-key/base-URL/model namespace.
For `openai`, that namespace is `CYBERAGENT_OPENAI_API_KEY`,
`CYBERAGENT_OPENAI_BASE_URL`, and `CYBERAGENT_OPENAI_MODEL`; its defaults are
`https://api.openai.com` and `gpt-4.1-mini`. The Provider object and secrets remain
inside the Go control plane and never enter Run configuration or event payloads.
The production Registry supplies a 60-second client timeout, and internal injected
clients are clamped to that maximum. The adapter disables redirects and emits only the
bearer authorization plus content negotiation headers required by Chat Completions;
custom headers and repository-selected compatibility modes are deliberately absent.
Repository files, including `configs/models.yaml`, are documentation rather than a
runtime Provider configuration source, so workspace content cannot inject endpoints,
headers, or credential references.

Connectivity diagnostics and Harness qualification publish a closed, content-free
failure reason (`none`, `not_configured`, `authentication`, `network`, `rate_limit`,
`capacity`, `model_not_found`, or `protocol_incompatible`) separately from the retry
outcome. HTTP/OpenAPI, CLI, Web, and Desktop project that safe category without
returning endpoint URLs, response bodies, raw errors, prompts, tool arguments, or keys.

Context is assembled from:

Expand Down
47 changes: 45 additions & 2 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,12 @@
"description": "Provider name",
"required": true,
"schema": {
"enum": [
"anthropic",
"deepseek",
"mimo",
"openai"
],
"maxLength": 256,
"minLength": 1,
"pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$",
Expand Down Expand Up @@ -14589,6 +14595,7 @@
"enum": [
"mock",
"anthropic_messages",
"openai_chat_completions",
"provider_contract"
],
"type": "string"
Expand Down Expand Up @@ -14648,6 +14655,19 @@
"minimum": 0,
"type": "integer"
},
"failure_reason": {
"enum": [
"none",
"not_configured",
"authentication",
"network",
"rate_limit",
"capacity",
"model_not_found",
"protocol_incompatible"
],
"type": "string"
},
"harness": {
"$ref": "#/components/schemas/ModelHarnessAvailabilityView"
},
Expand Down Expand Up @@ -14713,6 +14733,7 @@
"model",
"status",
"outcome",
"failure_reason",
"retryable",
"network_request_attempted",
"model_calls",
Expand Down Expand Up @@ -15724,7 +15745,8 @@
"kind": {
"enum": [
"local",
"anthropic_compatible"
"anthropic_compatible",
"openai_compatible"
],
"type": "string"
},
Expand Down Expand Up @@ -15768,7 +15790,8 @@
"items": {
"$ref": "#/components/schemas/ProviderCredentialStatusView"
},
"maxItems": 3,
"maxItems": 4,
"minItems": 4,
"type": "array"
},
"protocol_version": {
Expand Down Expand Up @@ -15833,6 +15856,12 @@
"type": "string"
},
"provider": {
"enum": [
"anthropic",
"deepseek",
"mimo",
"openai"
],
"type": "string"
},
"registry_generation": {
Expand Down Expand Up @@ -15900,6 +15929,19 @@
"minimum": 0,
"type": "integer"
},
"failure_reason": {
"enum": [
"none",
"not_configured",
"authentication",
"network",
"rate_limit",
"capacity",
"model_not_found",
"protocol_incompatible"
],
"type": "string"
},
"model": {
"type": "string"
},
Expand Down Expand Up @@ -15952,6 +15994,7 @@
"model",
"status",
"outcome",
"failure_reason",
"retryable",
"network_request_attempted",
"model_called",
Expand Down
10 changes: 8 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,19 +734,25 @@ cyberagent provider list
cyberagent provider test
cyberagent provider test mimo/mimo-v2.5-pro
cyberagent provider test deepseek/deepseek-v4-flash
cyberagent provider test openai/gpt-4.1-mini
cyberagent provider qualify script
cyberagent provider qualify mimo/mimo-v2.5-pro
cyberagent provider qualify openai/gpt-4.1-mini
cyberagent model list
cyberagent model set script mock/mock-code
```

`provider test` accepts either a route name, such as `learn`, or a direct `provider/model` reference. It is an explicit online connectivity diagnostic: each invocation may send one small, content-free, tool-disabled model request with a 15-second deadline and may incur Provider charges. Passing it does not qualify streamed ToolCall, ToolResult, or strict-JSON behavior.
`provider test` accepts either a route name, such as `learn`, or a direct `provider/model` reference. It is an explicit online connectivity diagnostic: each invocation may send one small, content-free, tool-disabled model request with a 15-second deadline and may incur Provider charges. Passing it does not qualify streamed ToolCall, ToolResult, or strict-JSON behavior. Its safe `failure_reason` distinguishes `not_configured`, `authentication`, `network`, `rate_limit`, `capacity`, `model_not_found`, and `protocol_incompatible` without returning raw Provider errors.

`provider qualify` accepts the same route or direct reference and performs the separate `model_harness_qualification.v1` contract. Built-in Mock returns immediately with zero model calls. An external model may receive at most two bounded requests under a 30-second deadline: one exact synthetic nonce ToolCall, then one exact strict-JSON acknowledgement after a synthetic ToolResult. Prayu never dispatches the synthetic Tool. Successful qualification is bound to the exact Provider/model/base-URL/strategy for seven days; a configuration change or expiry fails closed. The command prints status, counts, protocol strategy, and readiness only. It never prints prompts, response content, Tool arguments, API keys, endpoints, environment-variable names, or raw errors.

`model set` validates and persists the route before changing the in-memory Router, so a failed SQLite write cannot create a process-only route. Route selection does not automatically qualify the model. Root, Specialist, and read-only Fan-out use one Go-owned Harness preflight immediately before every Provider call; external models that have not passed the exact qualification are rejected before normal Agent execution.

The optional `mimo`, `deepseek`, and `anthropic` Providers load credentials from the process environment first and may then use the Go-owned system credential store. Windows stores exact supported keys in Credential Manager; non-Windows has no plaintext-file fallback. Credential status and mutation never return plaintext and a change currently requires process restart. Base URLs must be absolute HTTPS URLs unless they target an exact loopback host over HTTP; embedded credentials, query strings, fragments, and redirects are rejected. API keys are bounded normalized UTF-8 without whitespace or control characters.
The optional `mimo`, `deepseek`, `anthropic`, and `openai` Providers load credentials from the process environment first and may then use the Go-owned system credential store. OpenAI-compatible configuration uses only `CYBERAGENT_OPENAI_API_KEY`, `CYBERAGENT_OPENAI_BASE_URL`, and `CYBERAGENT_OPENAI_MODEL`; the base URL and model default to `https://api.openai.com` and `gpt-4.1-mini`. Its strict Chat Completions profile uses a 60-second production HTTP deadline, clamps internal injected clients to that maximum, follows no redirects, sends only `Authorization`, `Content-Type`, and `Accept`, and accepts no caller- or repository-supplied custom headers or compatibility fields. The OS credential name is `openai`. Do not use generic `OPENAI_*` variables for this adapter.

Windows stores exact supported keys in Credential Manager; non-Windows has no plaintext-file fallback. Credential status and mutation never return plaintext. Desktop and API control planes atomically reload a new Registry generation after a successful change; a host without the reload dependency reports `restart_required: true`. Base URLs must be absolute HTTPS URLs unless they target an exact loopback host over HTTP; embedded credentials, query strings, fragments, and redirects are rejected. API keys are bounded normalized UTF-8 without whitespace or control characters. [`../configs/models.yaml`](../configs/models.yaml) is a documentation-only, non-secret example and is not loaded at runtime; repository or Workspace content cannot configure a Provider endpoint, header, or key.

For an opt-in live smoke, set the three `CYBERAGENT_OPENAI_*` variables in the current process, then use the exact configured model reference. With the default model, run `cyberagent provider test openai/gpt-4.1-mini` followed by `cyberagent provider qualify openai/gpt-4.1-mini`; if `CYBERAGENT_OPENAI_MODEL` has another value, replace `gpt-4.1-mini` in both commands with that exact value. These commands make real, potentially billable network calls and are never part of the default test suite. Use a local mock endpoint for automated smoke tests. Neither command prints the API key, configured endpoint, prompts, response text, Tool arguments, raw deltas, or raw errors; unset the key after the manual check.

## Durable Run Wake Intent

Expand Down
18 changes: 12 additions & 6 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -676,8 +676,12 @@ func (a *App) providerCommand(ctx context.Context, args []string) error {
}
switch args[0] {
case "list":
for _, name := range a.router.ProviderNames() {
fmt.Fprintln(a.out, name)
if a.models == nil {
return errors.New("model registry is unavailable")
}
for _, provider := range a.models.Snapshot().Providers {
fmt.Fprintf(a.out, "%s\t%s\t%s\n", provider.Name, provider.Kind,
provider.Status)
}
return nil
case "test":
Expand All @@ -700,9 +704,10 @@ func (a *App) providerCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
fmt.Fprintf(a.out, "protocol: %s\nprovider: %s\nmodel: %s\nstatus: %s\noutcome: %s\nretryable: %t\nnetwork_request_attempted: %t\nmodel_called: %t\ntool_called: %t\nresponse_content_returned: %t\nduration_ms: %d\n",
fmt.Fprintf(a.out, "protocol: %s\nprovider: %s\nmodel: %s\nstatus: %s\noutcome: %s\nfailure_reason: %s\nretryable: %t\nnetwork_request_attempted: %t\nmodel_called: %t\ntool_called: %t\nresponse_content_returned: %t\nduration_ms: %d\n",
result.ProtocolVersion, result.Provider, result.Model, result.Status,
result.Outcome, result.Retryable, result.NetworkRequestAttempted,
result.Outcome, result.FailureReason, result.Retryable,
result.NetworkRequestAttempted,
result.ModelCalled, result.ToolCalled, result.ResponseContentReturned,
result.DurationMillis)
return nil
Expand All @@ -725,9 +730,10 @@ func (a *App) providerCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
fmt.Fprintf(a.out, "protocol: %s\nprovider: %s\nmodel: %s\nstatus: %s\noutcome: %s\nretryable: %t\nnetwork_request_attempted: %t\nmodel_calls: %d\nsynthetic_tool_calls: %d\ntool_executed: %t\nresponse_content_returned: %t\ntransport_protocol: %s\ntool_strategy: %s\njson_strategy: %s\nqualification_status: %s\nroot_eligible: %t\nduration_ms: %d\n",
fmt.Fprintf(a.out, "protocol: %s\nprovider: %s\nmodel: %s\nstatus: %s\noutcome: %s\nfailure_reason: %s\nretryable: %t\nnetwork_request_attempted: %t\nmodel_calls: %d\nsynthetic_tool_calls: %d\ntool_executed: %t\nresponse_content_returned: %t\ntransport_protocol: %s\ntool_strategy: %s\njson_strategy: %s\nqualification_status: %s\nroot_eligible: %t\nduration_ms: %d\n",
result.ProtocolVersion, result.Provider, result.Model, result.Status,
result.Outcome, result.Retryable, result.NetworkRequestAttempted,
result.Outcome, result.FailureReason, result.Retryable,
result.NetworkRequestAttempted,
result.ModelCalls, result.SyntheticToolCalls, result.ToolExecuted,
result.ResponseContentReturned, result.Harness.TransportProtocol,
result.Harness.ToolStrategy, result.Harness.JSONStrategy,
Expand Down
10 changes: 5 additions & 5 deletions internal/app/provider_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func TestDeepSeekProviderRegistersFromEnvironmentAndUsesDefaults(t *testing.T) {
if code != 0 || stderr != "" || !strings.Contains(output, "provider: deepseek") ||
!strings.Contains(output, "model: deepseek-v4-flash") ||
!strings.Contains(output, "status: reachable") ||
!strings.Contains(output, "failure_reason: none") ||
!strings.Contains(output, "response_content_returned: false") {
t.Fatalf("unexpected DeepSeek test output=%q stderr=%q code=%d", output, stderr, code)
}
Expand All @@ -66,15 +67,14 @@ func TestDeepSeekProviderRegistersFromEnvironmentAndUsesDefaults(t *testing.T) {
}
}

func TestDeepSeekProviderRequiresEnvironmentKey(t *testing.T) {
func TestProviderListProjectsUnconfiguredProvidersWithoutRegisteringThem(t *testing.T) {
t.Setenv("DEEPSEEK_API_KEY", "")
listed, stderr, code := executeTestCommand(t, "provider", "list")
if code != 0 || stderr != "" {
t.Fatalf("provider list failed: output=%q stderr=%q code=%d", listed, stderr, code)
}
for _, name := range strings.Fields(listed) {
if name == "deepseek" {
t.Fatal("DeepSeek provider was registered without an API key")
}
if !strings.Contains(listed, "deepseek\tanthropic_compatible\tnot_configured") ||
!strings.Contains(listed, "openai\topenai_compatible\tnot_configured") {
t.Fatalf("unconfigured Provider status was not projected: %q", listed)
}
}
21 changes: 19 additions & 2 deletions internal/application/model_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (s *ModelControlService) Diagnose(ctx context.Context,
return modelregistry.DiagnosticResult{}, apperror.New(
apperror.CodeInvalidArgument, "Provider diagnostic request is invalid")
}
if !snapshotContainsProviderModel(s.registry.Snapshot(), request.Provider, request.Model) {
if !snapshotContainsKnownProviderModel(s.registry.Snapshot(), request.Provider, request.Model) {
return modelregistry.DiagnosticResult{}, apperror.New(
apperror.CodeFailedPrecondition, "diagnostic Provider model is unavailable")
}
Expand All @@ -118,7 +118,7 @@ func (s *ModelControlService) QualifyHarness(ctx context.Context,
return modelregistry.HarnessQualificationResult{}, apperror.New(
apperror.CodeInvalidArgument, "model Harness qualification request is invalid")
}
if !snapshotContainsProviderModel(s.registry.Snapshot(), request.Provider, request.Model) {
if !snapshotContainsKnownProviderModel(s.registry.Snapshot(), request.Provider, request.Model) {
return modelregistry.HarnessQualificationResult{}, apperror.New(
apperror.CodeFailedPrecondition,
"model Harness qualification Provider model is unavailable")
Expand Down Expand Up @@ -168,3 +168,20 @@ func snapshotContainsProviderModel(snapshot modelregistry.Snapshot,
}
return false
}

func snapshotContainsKnownProviderModel(snapshot modelregistry.Snapshot,
provider string, model string,
) bool {
for _, current := range snapshot.Providers {
if current.Name != provider || (current.Status != modelregistry.ProviderAvailable &&
current.Status != modelregistry.ProviderNotConfigured) {
continue
}
for _, candidate := range current.Models {
if candidate == model {
return true
}
}
}
return false
}
Loading