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
12 changes: 11 additions & 1 deletion cli/agent_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,15 @@ type AgentErrorRecovery struct {
Hint string `json:"hint"`
}

// AgentErrorSchemaVersion is the current schema version of the agent error
// envelope. It is protocol metadata, not error content; consumers must ignore
// unknown fields. Backward-compatible field additions do not bump the
// version; only breaking shape changes do.
const AgentErrorSchemaVersion = 1

type AgentErrorEnvelope struct {
Message string `json:"message"`
Message string `json:"message"`
SchemaVersion int `json:"schema_version"`
// Structured server-error facts, populated only for remote server errors so
// agents can branch on them instead of parsing the message string.
ErrorCode string `json:"error_code,omitempty"`
Expand All @@ -64,6 +71,9 @@ type AgentError struct {
// NewAgentError returns nil when the required compact-envelope fields are
// incomplete. Optional data is normalized before it can reach JSON output.
func NewAgentError(envelope AgentErrorEnvelope, cause error) *AgentError {
if envelope.SchemaVersion == 0 {
envelope.SchemaVersion = AgentErrorSchemaVersion
}
envelope.DidYouMean = compactStrings(envelope.DidYouMean)
envelope.Recovery.Command = strings.TrimSpace(envelope.Recovery.Command)
if strings.TrimSpace(envelope.Message) == "" ||
Expand Down
26 changes: 26 additions & 0 deletions cli/agent_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func TestAgentErrorPreservesCompactLocalEnvelope(t *testing.T) {
require.NoError(t, marshalErr)
assert.JSONEq(t, `{
"message":"unknown flag --instnace-type",
"schema_version":1,
"did_you_mean":["--instance-type"],
"recovery":{
"action":"search_parameter",
Expand Down Expand Up @@ -113,6 +114,7 @@ func TestAgentErrorRecursivelyOmitsEmptyOptionalValues(t *testing.T) {
require.NoError(t, marshalErr)
assert.JSONEq(t, `{
"message":"invalid local usage",
"schema_version":1,
"did_you_mean":["--instance-id"],
"recovery":{
"action":"inspect_request_help",
Expand All @@ -135,6 +137,7 @@ func TestAgentErrorOmitsEmptyOptionalFields(t *testing.T) {
require.NoError(t, marshalErr)
assert.JSONEq(t, `{
"message":"missing required parameter(s): --region-id",
"schema_version":1,
"recovery":{
"action":"inspect_request_help",
"hint":"Inspect the complete request help."
Expand All @@ -144,6 +147,29 @@ func TestAgentErrorOmitsEmptyOptionalFields(t *testing.T) {
assert.NotContains(t, string(encoded), "command")
}

// The schema version is protocol metadata injected at the single construction
// point: unset envelopes get the current version, explicitly pinned producer
// versions are preserved.
func TestAgentErrorSchemaVersion(t *testing.T) {
valid := AgentErrorEnvelope{
Message: "invalid local usage",
Recovery: AgentErrorRecovery{
Action: "inspect_request_help",
Hint: "Inspect the request help.",
},
}

err := NewAgentError(valid, errors.New("cause"))
require.NotNil(t, err)
assert.Equal(t, AgentErrorSchemaVersion, err.Envelope().SchemaVersion)

pinned := valid
pinned.SchemaVersion = AgentErrorSchemaVersion + 1
err = NewAgentError(pinned, errors.New("cause"))
require.NotNil(t, err)
assert.Equal(t, AgentErrorSchemaVersion+1, err.Envelope().SchemaVersion)
}

func TestAIModeEnableHintsShareStableContent(t *testing.T) {
assert.Equal(t, "export ALIBABA_CLOUD_CLI_AI_MODE=1", NewAIModeHint().Command)
assert.Equal(t, "Enable AI Mode for compact Help, structured JSON errors, and actionable recovery guidance.", NewAIModeHint().Message)
Expand Down
2 changes: 1 addition & 1 deletion cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ func TestProcessAgentErrorWritesOneJSONLineToStderr(t *testing.T) {
cmd.processError(ctx, err)

assert.Empty(t, stdout.String())
assert.Equal(t, "{\"message\":\"unknown flag --instnace-type\",\"did_you_mean\":[\"--instance-type\"],\"recovery\":{\"action\":\"search_parameter\",\"command\":\"aliyun ecs describe-instances --help-search instance-type\",\"hint\":\"Search request parameters related to instance-type.\"}}\n", stderr.String())
assert.Equal(t, "{\"message\":\"unknown flag --instnace-type\",\"schema_version\":1,\"did_you_mean\":[\"--instance-type\"],\"recovery\":{\"action\":\"search_parameter\",\"command\":\"aliyun ecs describe-instances --help-search instance-type\",\"hint\":\"Search request parameters related to instance-type.\"}}\n", stderr.String())
assert.NotContains(t, stderr.String(), AIModeEnableTextHint)
}

Expand Down
17 changes: 15 additions & 2 deletions docs/en/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,20 @@ export ALIBABA_CLOUD_CLI_AI_MODE=1
aliyun ecs describe-instances --cli-ai-mode
```

AI mode takes effect in this order of precedence (highest first):

1. `--no-cli-ai-mode` on a single command (explicit opt-out, always wins)
2. `--cli-ai-mode` on a single command (explicit opt-in)
3. The `ALIBABA_CLOUD_CLI_AI_MODE=1/0` environment variable (explicit value)
4. Agent-environment auto-detection (gated by `ALIBABA_CLOUD_CLI_AGENT_INTEGRATION`, enabled by default)
5. The global `configure ai-mode` setting (`~/.aliyun/ai-mode.json`, off by default)

Supported local usage, query, transport, OAuth, and server errors are written as one compact JSON object to stderr. Success output remains on stdout. Optional fields are omitted when unavailable:

```json
{
"message": "unknown flag --instnace-type",
"schema_version": 1,
"did_you_mean": ["--instance-type"],
"recovery": {
"action": "search_parameter",
Expand All @@ -143,9 +152,13 @@ Supported local usage, query, transport, OAuth, and server errors are written as
}
```

Remote server errors may additionally include `error_code`, `status_code`, and `request_id`. `did_you_mean` and `recovery.command` are also optional; `message`, `recovery.action`, and `recovery.hint` are present in every structured Agent error.
Remote server errors may additionally include `error_code`, `status_code`, and `request_id`. `did_you_mean` and `recovery.command` are also optional; `message`, `schema_version`, `recovery.action`, and `recovery.hint` are present in every structured Agent error.

`schema_version` is the envelope's protocol version (currently `1`) — protocol metadata, not error content. Backward-compatible field additions do not bump it; only breaking shape changes do. Consumers should ignore unknown fields and branch on `schema_version`.

In AI mode, API responses are written as compact single-line JSON preserving the server's key order; other modes pretty-print with sorted keys. JSON key order is not part of the contract — never parse it byte-wise.

The Agent error object is a separate compact interface and currently has no `schemaVersion`; the Machine Help `v1` contract does not apply to it. Not every error is normalized yet, so consumers must also tolerate human-readable stderr.
The Agent error object is a compact protocol separate from Machine Help: Machine Help uses `schemaVersion: "v1"` (camelCase, on stdout), while Agent errors use `schema_version: 1` (snake_case, on stderr). Not every error is normalized yet, so consumers must also tolerate human-readable stderr.

| Exit status | Meaning |
| --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/en/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ aliyun configure ai-mode --help
aliyun ecs describe-instances --cli-ai-mode
```

When a supported agent environment is detected, in-process OpenAPI commands automatically enable agent-oriented interaction and execution optimizations. These optimizations currently include stricter metadata-based validation and more structured error output, but the exact behavior may change and is not a stable compatibility contract.
When a supported agent environment is detected, in-process OpenAPI commands automatically enable agent-oriented interaction and execution optimizations. These optimizations currently include stricter metadata-based validation and more structured error output; the Agent error envelope is versioned via `schema_version` (see [MCP proxy, OpenTelemetry, and machine-readable interfaces](./integrations.md)), while other optimizations may change across versions.

Requests made through this automatically enabled mode append the following generic User-Agent marker:

Expand Down
17 changes: 15 additions & 2 deletions docs/zh-CN/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,20 @@ export ALIBABA_CLOUD_CLI_AI_MODE=1
aliyun ecs describe-instances --cli-ai-mode
```

AI mode 的生效优先级(从高到低):

1. 单次命令 `--no-cli-ai-mode`(显式关闭,永远优先)
2. 单次命令 `--cli-ai-mode`(显式开启)
3. 环境变量 `ALIBABA_CLOUD_CLI_AI_MODE=1/0`(显式值)
4. Agent 环境自动探测(受 `ALIBABA_CLOUD_CLI_AGENT_INTEGRATION` 门控,默认开启)
5. 全局配置 `configure ai-mode`(`~/.aliyun/ai-mode.json`,兜底默认关闭)

目前支持的本地用法错误、查询错误、传输错误、OAuth 错误和服务端错误会以单个紧凑 JSON 对象写入 stderr;成功结果仍写入 stdout。没有值的可选字段会被省略:

```json
{
"message": "unknown flag --instnace-type",
"schema_version": 1,
"did_you_mean": ["--instance-type"],
"recovery": {
"action": "search_parameter",
Expand All @@ -143,9 +152,13 @@ aliyun ecs describe-instances --cli-ai-mode
}
```

远端服务错误还可能包含 `error_code`、`status_code` 和 `request_id`。`did_you_mean` 和 `recovery.command` 也是可选字段;每个结构化 Agent 错误都会包含 `message`、`recovery.action` 和 `recovery.hint`。
远端服务错误还可能包含 `error_code`、`status_code` 和 `request_id`。`did_you_mean` 和 `recovery.command` 也是可选字段;每个结构化 Agent 错误都会包含 `message`、`schema_version`、`recovery.action` 和 `recovery.hint`。

`schema_version` 是信封的协议版本(当前为 `1`),属于协议元数据而非错误内容。向后兼容的新增字段不升版本;只有破坏性变更才会递增。调用方应忽略未知字段,并按 `schema_version` 分支处理。

Agent 模式下的 API 响应输出为紧凑单行 JSON(保留服务端返回的 key 顺序);非 Agent 模式为美化缩进并按 key 排序。JSON key 顺序不属于契约,解析时请不要依赖字节顺序。

Agent 错误对象是一套独立的紧凑接口,目前没有 `schemaVersion`;机器 Help 的 `v1` 协议不适用于它。并非所有错误都已经结构化,因此调用方还必须兼容 stderr 中的人类可读错误。
Agent 错误对象是独立于机器 Help 的紧凑协议:机器 Help 使用 `schemaVersion: "v1"`(camelCase,写入 stdout),Agent 错误使用 `schema_version: 1`(snake_case,写入 stderr),两者不混用。并非所有错误都已经结构化,因此调用方还必须兼容 stderr 中的人类可读错误。

| 退出状态 | 含义 |
| --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ aliyun configure ai-mode --help
aliyun ecs describe-instances --cli-ai-mode
```

检测到受支持的 Agent 环境时,进程内 OpenAPI 命令会自动启用面向 Agent 的交互和执行优化。目前包括更严格的 metadata 参数校验和更结构化的错误输出,但具体优化行为可能随版本调整,不属于稳定兼容协议
检测到受支持的 Agent 环境时,进程内 OpenAPI 命令会自动启用面向 Agent 的交互和执行优化。目前包括更严格的 metadata 参数校验和更结构化的错误输出;其中 Agent 错误信封已通过 `schema_version` 版本化(见 [MCP 代理、OpenTelemetry 与机器可读接口](./integrations.md)),其余优化行为可能随版本调整

通过自动探测启用后,请求会增加以下通用 UA 标记:

Expand Down
113 changes: 113 additions & 0 deletions openapi/agent_aimode_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package openapi

import (
"bytes"
"errors"
"fmt"
"io"
"testing"

"github.com/aliyun/aliyun-cli/v3/cli"
"github.com/aliyun/aliyun-cli/v3/config"
"github.com/aliyun/aliyun-cli/v3/sysconfig/aimode"
"github.com/aliyun/aliyun-openapi-runtime/argparser"
"github.com/aliyun/aliyun-openapi-runtime/engine"
"github.com/stretchr/testify/assert"
)

func TestCliAIOverridesForOpenAPIIncludesDetectedAgent(t *testing.T) {
Expand Down Expand Up @@ -84,3 +91,109 @@ func TestDetectedAgentAddsLegacyOpenAPIUserAgent(t *testing.T) {
t.Fatalf("force-off legacy OpenAPI suffix = %q", suffix)
}
}

// TestAIModeEffectivePrecedenceChain locks the documented AI-mode precedence:
// --no-cli-ai-mode > --cli-ai-mode > ALIBABA_CLOUD_CLI_AI_MODE > agent
// auto-detection (gated by ALIBABA_CLOUD_CLI_AGENT_INTEGRATION) > ai-mode.json.
// The contract is frozen by test so later refactors cannot silently reorder it.
func TestAIModeEffectivePrecedenceChain(t *testing.T) {
newCtx := func(agent bool) *cli.Context {
ctx := cli.NewCommandContext(io.Discard, io.Discard)
AddFlags(ctx.Flags())
if agent {
ctx.SetAgentName("codex")
}
return ctx
}
enabledFor := func(ctx *cli.Context, cfg *aimode.AiConfig) bool {
on, off := CliAIOverridesForOpenAPI(ctx)
return aimode.EnabledForCommand(cfg, on, off)
}

t.Run("default off", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "")
t.Setenv(aimode.EnvAgentIntegration, "")
assert.False(t, enabledFor(newCtx(false), &aimode.AiConfig{Enabled: false}))
})

t.Run("config file on", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "")
t.Setenv(aimode.EnvAgentIntegration, "")
assert.True(t, enabledFor(newCtx(false), &aimode.AiConfig{Enabled: true}))
})

t.Run("env on beats config off", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "1")
assert.True(t, enabledFor(newCtx(false), &aimode.AiConfig{Enabled: false}))
})

t.Run("env off beats config on", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "0")
assert.False(t, enabledFor(newCtx(false), &aimode.AiConfig{Enabled: true}))
})

t.Run("agent auto-detection turns on", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "")
t.Setenv(aimode.EnvAgentIntegration, "")
assert.True(t, enabledFor(newCtx(true), &aimode.AiConfig{Enabled: false}))
})

t.Run("agent auto-detection gated off by integration switch", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "")
t.Setenv(aimode.EnvAgentIntegration, "disabled")
assert.False(t, enabledFor(newCtx(true), &aimode.AiConfig{Enabled: false}))
})

t.Run("env opt-out beats agent detection", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "0")
assert.False(t, enabledFor(newCtx(true), &aimode.AiConfig{Enabled: false}))
})

t.Run("flag on beats env off", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "0")
ctx := newCtx(false)
CliAIModeFlag(ctx.Flags()).SetAssigned(true)
assert.True(t, enabledFor(ctx, &aimode.AiConfig{Enabled: false}))
})

t.Run("flag off beats everything", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "1")
t.Setenv(aimode.EnvAgentIntegration, "")
ctx := newCtx(true)
CliNoAIModeFlag(ctx.Flags()).SetAssigned(true)
assert.False(t, enabledFor(ctx, &aimode.AiConfig{Enabled: true}))
})

t.Run("flag on beats integration gate", func(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "")
t.Setenv(aimode.EnvAgentIntegration, "disabled")
ctx := newCtx(true)
CliAIModeFlag(ctx.Flags()).SetAssigned(true)
assert.True(t, enabledFor(ctx, &aimode.AiConfig{Enabled: false}))
})
}

// TestExplicitOptOutKeepsHumanErrorUnderAgentDetection covers the opt-out path
// end to end: a detected agent environment plus --no-cli-ai-mode renders the
// human error, not the JSON envelope.
func TestExplicitOptOutKeepsHumanErrorUnderAgentDetection(t *testing.T) {
t.Setenv(aimode.EnvAIMode, "")
t.Setenv(aimode.EnvAgentIntegration, "")
ctx := cli.NewCommandContext(new(bytes.Buffer), new(bytes.Buffer))
cmd := &cli.Command{Name: "aliyun", EnableUnknownFlag: true}
config.AddFlags(cmd.Flags())
AddFlags(cmd.Flags())
ctx.EnterCommand(cmd)
ctx.SetAgentName("codex")

commando := &Commando{profile: config.Profile{Language: "en"}}
cause := &engine.UsageError{
Code: "UNKNOWN_FLAG",
Err: fmt.Errorf("%w (run `aliyun ecs describe-instances --help` for accepted flags)",
&argparser.UnknownFlagError{Flag: "Profile", Known: []string{"instance-type"}}),
}
got := commando.finishCommandRun(ctx, []string{"ecs", "describe-instances", "--no-cli-ai-mode", "--Profile", "default"}, cause)
var agentErr *cli.AgentError
assert.False(t, errors.As(got, &agentErr), "opt-out must not produce a JSON envelope")
assert.Contains(t, got.Error(), "did you mean --profile")
}
46 changes: 41 additions & 5 deletions openapi/agent_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ func normalizeAgentErrorWithSearch(err error, args []string, validate RecoverySe

context := newRecoveryContext(args)

var styleMixed *styleMixedFlagError
if errors.As(err, &styleMixed) {
recovery := cli.AgentErrorRecovery{
Action: "inspect_action_help",
Command: context.actionHelpCommand(),
Hint: fmt.Sprintf("--%s is a PascalCase parameter name; the kebab command accepts --%s.", styleMixed.flag, strings.TrimPrefix(styleMixed.suggestion, "--")),
}
if styleMixed.equivalent != "" {
recovery = cli.AgentErrorRecovery{
Action: "switch_command_style",
Command: styleMixed.equivalent,
Hint: "The flag belongs to the PascalCase command style. Run the equivalent PascalCase command, or keep the kebab command and use the suggested flag.",
}
}
return newLocalAgentError(err, styleMixed.AgentMessage(), styleMixed.AgentSuggestions(), recovery)
}

var unknownFlag *argparser.UnknownFlagError
if errors.As(err, &unknownFlag) {
suggestions := flagSuggestions(unknownFlag.Flag, unknownFlag.Known)
Expand All @@ -84,17 +101,25 @@ func normalizeAgentErrorWithSearch(err error, args []string, validate RecoverySe

var missing *runtime.MissingRequiredError
if errors.As(err, &missing) {
return missingRequiredAgentError(err, missingRequiredAgentMessage(missing), context)
// The --region routing-flag clarification is computed once in
// finishCommandRun (where the parsed flags are available) and carried
// by the wrapper; here it enriches the hint without a second parse.
note := ""
var regionErr *regionConfusionError
if errors.As(err, &regionErr) {
note = regionErr.note
}
return missingRequiredAgentError(err, missingRequiredAgentMessage(missing), context, note)
}

var legacyDocRequired *LegacyDocRequiredError
if errors.As(err, &legacyDocRequired) {
return missingRequiredAgentError(err, legacyDocRequired.Error(), context)
return missingRequiredAgentError(err, legacyDocRequired.Error(), context, "")
}

var legacyMissingRequired *LegacyMissingRequiredError
if errors.As(err, &legacyMissingRequired) {
return missingRequiredAgentError(err, legacyMissingRequired.Error(), context)
return missingRequiredAgentError(err, legacyMissingRequired.Error(), context, "")
}

var runtimeConstraint *runtime.ConstraintViolationError
Expand All @@ -111,6 +136,13 @@ func normalizeAgentErrorWithSearch(err error, args []string, validate RecoverySe
if errors.As(err, &invalidParameter) {
parameterContext := context.withProductAPI(invalidParameter.ProductCode, invalidParameter.ApiName)
suggestions := invalidParameter.AgentSuggestions()
if invalidParameter.equivalentCommand != "" {
return newLocalAgentError(err, invalidParameter.AgentMessage(), suggestions, cli.AgentErrorRecovery{
Action: "switch_command_style",
Command: invalidParameter.equivalentCommand,
Hint: "The flag belongs to the kebab command style. Run the equivalent kebab command, or keep the PascalCase command and use the suggested flag.",
})
}
return parameterSearchAgentError(err, invalidParameter.AgentMessage(), suggestions,
invalidParameter.Name, parameterContext, validate)
}
Expand Down Expand Up @@ -732,11 +764,15 @@ func missingRequiredAgentMessage(err *runtime.MissingRequiredError) string {
return "missing required parameter(s): " + strings.Join(err.Flags, ", ")
}

func missingRequiredAgentError(cause error, message string, context recoveryContext) error {
func missingRequiredAgentError(cause error, message string, context recoveryContext, note string) error {
hint := "Inspect the API help for request parameters and provide every required value."
if note != "" {
hint = hint + " " + note
}
return newLocalAgentError(cause, message, nil, cli.AgentErrorRecovery{
Action: "inspect_request_help",
Command: context.actionHelpCommand(),
Hint: "Inspect the API help for request parameters and provide every required value.",
Hint: hint,
})
}

Expand Down
Loading
Loading