WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
// Package agentcmd holds the `weknora agent` command tree:
|
||||
// list / view / create / update / delete / status / check. The directory is
|
||||
// named `agent/` to match the cobra subcommand; the Go package is `agentcmd`
|
||||
// to avoid colliding with cobra's *cobra.Command identifier.
|
||||
//
|
||||
// "agent" in this subtree refers to WeKnora's user-defined Custom
|
||||
// Agents (server resource: GET/POST /agents/...) and handles CRUD
|
||||
// operations only. Agent invocation has moved to `weknora session ask
|
||||
// --agent <id>` (see cli/cmd/session/ask.go).
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora agent` parent and registers leaves. Called
|
||||
// from cli/cmd/root.go.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Manage custom agents (CRUD + status/check)",
|
||||
Long: `Custom Agents bundle a system prompt, model, tool allow-list, and KB
|
||||
scope into an addressable resource. Create, update, list, view, check,
|
||||
or delete agents. To invoke an agent, use: weknora session ask --agent <id>`,
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdCreate(f))
|
||||
cmd.AddCommand(NewCmdEdit(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdCheck(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// AgentCheckResult is the deep-verification response for `agent check <id>`.
|
||||
// Superset of AgentStatusResult: status fields + KBScopeAllReachable from
|
||||
// probing each KB in agent.Config.KnowledgeBases. Verb split with
|
||||
// `agent status`: status reads existing state cheaply, check actively
|
||||
// verifies dependencies.
|
||||
type AgentCheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
KBScopeAllReachable *bool `json:"kb_scope_all_reachable,omitempty"` // pointer so we can distinguish "not applicable" (e.g. agent unreachable) from "false"
|
||||
}
|
||||
|
||||
// AgentCheckService is the narrow SDK surface needed for agent check.
|
||||
type AgentCheckService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
var agentCheckFields = []string{"id", "reachable", "model_id", "kb_scope_all_reachable"}
|
||||
|
||||
// NewCmdCheck builds `weknora agent check <id>`.
|
||||
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "check <agent-id>",
|
||||
Short: "Verify a custom agent end-to-end (status + kb_scope reachability)",
|
||||
Long: `Active verification of a custom agent.
|
||||
|
||||
Performs 1 + N HTTP calls:
|
||||
1 GET /agents/{id} — reachable + model_id
|
||||
N GET /kb/{kb_id} for each id in agent.config.knowledge_bases
|
||||
— sets kb_scope_all_reachable = true iff every probe succeeds
|
||||
|
||||
Use 'weknora agent status <id>' for a fast read-only health snapshot
|
||||
(1 HTTP call, no KB probing). Use 'agent check' when you need to verify
|
||||
the agent's downstream dependencies are all reachable.`,
|
||||
Example: ` weknora agent check ag_abc
|
||||
weknora agent check ag_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := runAgentCheck(c.Context(), cli, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitAgentCheck(res, fopts, iostreams.IO.Out)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentCheckFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "verify a custom agent end-to-end: status plus kb_scope reachability",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Examples: []string{"weknora agent check agent_abc"},
|
||||
Output: "envelope.data is {id, reachable, ...} with kb_scope reachability folded in",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runAgentCheck is the testable core. Never errors for "agent not
|
||||
// reachable" — Reachable=false carries that signal.
|
||||
func runAgentCheck(ctx context.Context, svc AgentCheckService, id string) (*AgentCheckResult, error) {
|
||||
a, err := svc.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
return &AgentCheckResult{ID: id, Reachable: false}, nil
|
||||
}
|
||||
res := &AgentCheckResult{ID: a.ID, Reachable: true}
|
||||
if a.Config != nil {
|
||||
res.ModelID = a.Config.ModelID
|
||||
}
|
||||
// Probe each KB in scope. Vacuously true when scope is empty or
|
||||
// config is nil.
|
||||
allOK := true
|
||||
if a.Config != nil {
|
||||
for _, kbID := range a.Config.KnowledgeBases {
|
||||
if _, err := svc.GetKnowledgeBase(ctx, kbID); err != nil {
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
res.KBScopeAllReachable = &allOK
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// emitAgentCheck renders res. Same dispatch as emitAgentStatus.
|
||||
func emitAgentCheck(res *AgentCheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, res, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeAgentCheckText(w, res)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for agent check", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentCheckText(w io.Writer, res *AgentCheckResult) error {
|
||||
fmt.Fprintf(w, "ID: %s\n", res.ID)
|
||||
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
fmt.Fprintf(w, "Model: %s\n", res.ModelID)
|
||||
}
|
||||
if res.KBScopeAllReachable != nil {
|
||||
fmt.Fprintf(w, "KB scope OK: %v\n", *res.KBScopeAllReachable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies AgentCheckService.
|
||||
var _ AgentCheckService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,154 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeAgentCheckSvc struct {
|
||||
agent *sdk.Agent
|
||||
agentErr error
|
||||
kbProbeOK map[string]bool // kb_id → reachable
|
||||
kbErr error // applied for any KB id not in the map
|
||||
}
|
||||
|
||||
func (f *fakeAgentCheckSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
if f.agentErr != nil {
|
||||
return nil, f.agentErr
|
||||
}
|
||||
return f.agent, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if ok, found := f.kbProbeOK[id]; found {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("kb %s unreachable", id)
|
||||
}
|
||||
return &sdk.KnowledgeBase{ID: id}, nil
|
||||
}
|
||||
if f.kbErr != nil {
|
||||
return nil, f.kbErr
|
||||
}
|
||||
return &sdk.KnowledgeBase{ID: id}, nil
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_AllReachable(t *testing.T) {
|
||||
svc := &fakeAgentCheckSvc{
|
||||
agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{
|
||||
ModelID: "m_x",
|
||||
KnowledgeBases: []string{"kb_a", "kb_b"},
|
||||
}},
|
||||
kbProbeOK: map[string]bool{"kb_a": true, "kb_b": true},
|
||||
}
|
||||
res, err := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if res.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", res.ModelID)
|
||||
}
|
||||
if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be true (pointer set), got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_OneKBFails(t *testing.T) {
|
||||
svc := &fakeAgentCheckSvc{
|
||||
agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{
|
||||
ModelID: "m_x",
|
||||
KnowledgeBases: []string{"kb_a", "kb_b"},
|
||||
}},
|
||||
kbProbeOK: map[string]bool{"kb_a": true, "kb_b": false},
|
||||
}
|
||||
res, _ := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if res.KBScopeAllReachable == nil || *res.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be false; got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_Unreachable(t *testing.T) {
|
||||
svc := &fakeAgentCheckSvc{agentErr: fmt.Errorf("404")}
|
||||
res, err := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if res.Reachable {
|
||||
t.Error("Reachable=true on 404; want false")
|
||||
}
|
||||
if res.ID != "ag_x" {
|
||||
t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID)
|
||||
}
|
||||
// KBScopeAllReachable should be nil when agent is unreachable
|
||||
if res.KBScopeAllReachable != nil {
|
||||
t.Errorf("KBScopeAllReachable should be nil when agent unreachable; got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_NilConfig(t *testing.T) {
|
||||
// Defensive: Agent.Config is a pointer; nil should not panic and
|
||||
// KBScopeAllReachable should be vacuously true (no KBs to probe).
|
||||
svc := &fakeAgentCheckSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}}
|
||||
res, err := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true on nil config")
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
t.Errorf("ModelID=%q, want empty (no config)", res.ModelID)
|
||||
}
|
||||
// Vacuously true: no KBs to probe
|
||||
if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be vacuously true for nil config; got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentCheck_JSON(t *testing.T) {
|
||||
trueP := true
|
||||
var buf bytes.Buffer
|
||||
res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := emitAgentCheck(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data AgentCheckResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if got.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", got.ModelID)
|
||||
}
|
||||
if got.KBScopeAllReachable == nil || !*got.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be true in JSON output; got %v", got.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentCheck_Text(t *testing.T) {
|
||||
trueP := true
|
||||
var buf bytes.Buffer
|
||||
res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
if err := emitAgentCheck(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
for _, want := range []string{"ag_x", "m_x", "true"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// CreateService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it via duck typing.
|
||||
type CreateService interface {
|
||||
CreateAgent(ctx context.Context, req *sdk.CreateAgentRequest) (*sdk.Agent, error)
|
||||
CopyAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
|
||||
}
|
||||
|
||||
// CreateOptions captures flag state. SystemPromptReader and ConfigFileBody
|
||||
// are populated from --system-prompt-file and --config-file respectively
|
||||
// (or stdin when the value is "-"). The embedded flags struct tracks "was
|
||||
// set" bits so MergeAgentConfig can distinguish "user passed --foo zero"
|
||||
// from "user did not pass --foo".
|
||||
type CreateOptions struct {
|
||||
Name string
|
||||
Model string
|
||||
Description string
|
||||
SystemPrompt string
|
||||
SystemPromptReader io.Reader
|
||||
AgentMode string
|
||||
KBs []string
|
||||
KBSelectionMode string
|
||||
RerankModel string
|
||||
Temperature float64
|
||||
From string
|
||||
ConfigFileBody io.Reader
|
||||
ConfigFileKind string // "yaml" or "json"
|
||||
GenerateSkeleton bool
|
||||
DryRun bool
|
||||
|
||||
flags createFlagSet // populated in PreRunE for *Set bits
|
||||
}
|
||||
|
||||
// createFlagSet records which hot-path flags the user explicitly passed so
|
||||
// MergeAgentConfig knows which fields to overlay onto the base config.
|
||||
type createFlagSet struct {
|
||||
agentModeSet bool
|
||||
systemPromptSet bool
|
||||
rerankModelSet bool
|
||||
temperatureSet bool
|
||||
kbSelectionModeSet bool
|
||||
kbsSet bool
|
||||
}
|
||||
|
||||
const agentCreateExample = ` weknora agent create "Support Bot" --model <model-id>
|
||||
weknora agent create "Code Reviewer" --model <model-id> --system-prompt-file ./prompt.md --attach-kb kb_eng --attach-kb kb_arch
|
||||
weknora agent create "From Template" --model <model-id> --from ag_existing
|
||||
weknora agent create --generate-skeleton > my-agent.yaml
|
||||
weknora agent create "Tuned" --model <model-id> --config-file ./my-agent.yaml`
|
||||
|
||||
const agentCreateLong = `Create a new custom agent.
|
||||
|
||||
--model is required (an agent without a model cannot invoke); discover a
|
||||
valid model id with 'weknora model list'. The 7
|
||||
optional hot-path flags cover the most frequently set AgentConfig fields;
|
||||
for the remaining 27 use --config-file with a YAML or JSON document
|
||||
matching the AgentConfig schema (run --generate-skeleton to get a
|
||||
ready-to-edit template).
|
||||
|
||||
Precedence when both a config file and hot-path flags are supplied:
|
||||
hot-path flag > config-file value > server default
|
||||
|
||||
--from <id> copies an existing agent (SDK CopyAgent) then applies any
|
||||
hot-path overrides via UpdateAgent. --attach-kb on --from REPLACES the copied
|
||||
agent's KB list (not merge) — matches surgical-flag semantics.
|
||||
|
||||
AI agents: writes a new resource server-side. Failure surfaces as a
|
||||
typed code on stderr: input.invalid_argument (bad flags, bad file, or
|
||||
bad model), resource.not_found (--from <missing>), auth.unauthenticated.`
|
||||
|
||||
// NewCmdCreate builds `weknora agent create <name> --model <id>`.
|
||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &CreateOptions{}
|
||||
var systemPromptFile, configFile string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new custom agent",
|
||||
Long: agentCreateLong,
|
||||
Example: agentCreateExample,
|
||||
// Allow 0 args for --generate-skeleton; PreRunE enforces the real
|
||||
// arity rule once it knows whether skeleton mode is active.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.GenerateSkeleton {
|
||||
return nil
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return cmdutil.NewFlagError(fmt.Errorf("accepts 1 arg, received %d", len(args)))
|
||||
}
|
||||
opts.Name = args[0]
|
||||
if opts.Model == "" {
|
||||
return cmdutil.NewFlagError(fmt.Errorf(`required flag(s) "model" not set`))
|
||||
}
|
||||
opts.flags.agentModeSet = cmd.Flags().Changed("agent-mode")
|
||||
opts.flags.systemPromptSet = cmd.Flags().Changed("system-prompt") || cmd.Flags().Changed("system-prompt-file")
|
||||
opts.flags.rerankModelSet = cmd.Flags().Changed("rerank-model")
|
||||
opts.flags.temperatureSet = cmd.Flags().Changed("temperature")
|
||||
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
|
||||
opts.flags.kbsSet = cmd.Flags().Changed("attach-kb")
|
||||
|
||||
// --temperature is bounded 0.0..2.0. Reject out-of-range
|
||||
// early with a typed input.invalid_argument so users don't
|
||||
// burn a roundtrip on a value the server would also reject.
|
||||
if opts.flags.temperatureSet && (opts.Temperature < 0.0 || opts.Temperature > 2.0) {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature))
|
||||
}
|
||||
|
||||
// Reject an unknown --agent-mode / --kb-selection-mode up front
|
||||
// (typed exit 5) rather than letting the server fail the create.
|
||||
if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if systemPromptFile != "" {
|
||||
r, err := cmdutil.OpenInput(systemPromptFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
|
||||
}
|
||||
opts.SystemPromptReader = r
|
||||
}
|
||||
if configFile != "" {
|
||||
r, kind, err := openConfigFile(configFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--config-file: %v", err))
|
||||
}
|
||||
opts.ConfigFileBody = r
|
||||
opts.ConfigFileKind = kind
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
planArgs := map[string]any{"name": opts.Name}
|
||||
if configFile != "" {
|
||||
planArgs["config"] = configFile
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "agent.create",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve --model / --rerank-model (id or name) and validate they
|
||||
// exist, matching the id-or-name policy of `kb config set`. A bogus name
|
||||
// fails fast here instead of creating an agent whose model never
|
||||
// resolves at run time.
|
||||
if opts.Model, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.Model, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.RerankModel, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.RerankModel, "Rerank"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve --attach-kb values (id or name) to canonical ids,
|
||||
// matching the --kb flag's id-or-name policy. Without this a raw
|
||||
// name is stored verbatim as a kb_id, silently creating an agent
|
||||
// whose KB reference never resolves at run time.
|
||||
if len(opts.KBs) > 0 {
|
||||
resolved := make([]string, 0, len(opts.KBs))
|
||||
for _, raw := range opts.KBs {
|
||||
id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
resolved = append(resolved, id)
|
||||
}
|
||||
opts.KBs = resolved
|
||||
}
|
||||
return runCreate(cmd.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
|
||||
// Required (enforced in PreRunE rather than cmd.MarkFlagRequired so
|
||||
// --generate-skeleton can bypass it — cobra's MarkFlagRequired runs
|
||||
// before PreRunE and would otherwise block the skeleton path).
|
||||
cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id or name (required, except with --generate-skeleton)")
|
||||
|
||||
// Hot-path (8 flag names, 7 distinct config fields)
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "Agent description")
|
||||
cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)")
|
||||
cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin")
|
||||
cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file")
|
||||
cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | "))
|
||||
cmd.Flags().StringSliceVar(&opts.KBs, "attach-kb", nil, "Attach a knowledge base id (repeatable); aligns with 'agent update --add-kb'")
|
||||
cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | "))
|
||||
cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id or name")
|
||||
cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)")
|
||||
|
||||
// Power-user / utility
|
||||
cmd.Flags().StringVar(&opts.From, "from", "", "Copy from existing agent id (then apply other flags)")
|
||||
cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (use '-' for stdin)")
|
||||
cmd.Flags().BoolVar(&opts.GenerateSkeleton, "generate-skeleton", false, "Emit blank AgentConfig YAML to stdout and exit")
|
||||
|
||||
cmdutil.AddFormatFlag(cmd, agentViewFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Create a new custom agent. --model is required and accepts a model id or name; --attach-kb (repeatable) accepts KB ids or names. Emits the created Agent object.",
|
||||
RequiredFlags: []string{"<name> (positional)", "--model"},
|
||||
Examples: []string{
|
||||
`weknora agent create "Support Bot" --model gpt-4o`,
|
||||
`weknora agent create "Support Bot" --model gpt-4o --attach-kb kb_eng --system-prompt "You are a support assistant."`,
|
||||
`weknora agent create --generate-skeleton # print a blank config to fill in (no --model needed)`,
|
||||
},
|
||||
Output: "envelope.data is the created Agent object with id, name, config",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService) error {
|
||||
if opts.GenerateSkeleton {
|
||||
return cmdutil.GenerateAgentSkeleton(iostreams.IO.Out)
|
||||
}
|
||||
|
||||
// 1. Build base AgentConfig from --config-file (if any), else zero.
|
||||
// (For --from, the copied agent's existing config becomes the base
|
||||
// instead — set after CopyAgent below, before MergeAgentConfig.)
|
||||
var base sdk.AgentConfig
|
||||
if opts.ConfigFileBody != nil {
|
||||
parsed, err := cmdutil.LoadAgentConfig(opts.ConfigFileBody, opts.ConfigFileKind)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, err.Error())
|
||||
}
|
||||
base = *parsed
|
||||
}
|
||||
|
||||
// 2. Resolve system prompt (file/stdin > flag string)
|
||||
if opts.SystemPromptReader != nil {
|
||||
body, err := io.ReadAll(opts.SystemPromptReader)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file read: %v", err))
|
||||
}
|
||||
opts.SystemPrompt = strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// 3. Either copy-then-update, or create from scratch. For --from we
|
||||
// must seed `base` from the copied agent's existing config FIRST so
|
||||
// MergeAgentConfig preserves source fields the user did not override
|
||||
// (e.g. SystemPrompt, AgentMode, KB list). Without this seeding the
|
||||
// surgical overrides would ship UpdateAgent with the other 33 fields
|
||||
// zeroed, clobbering source state.
|
||||
if opts.From != "" {
|
||||
copied, err := svc.CopyAgent(ctx, opts.From)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "copy agent %s", opts.From)
|
||||
}
|
||||
if copied.Config != nil {
|
||||
base = *copied.Config
|
||||
}
|
||||
|
||||
// --attach-kb on --from REPLACES the copied KB list; when --attach-kb
|
||||
// is not set, KnowledgeBasesSet stays false inside applyCreateOverrides
|
||||
// and the copy's KB list passes through unchanged.
|
||||
cfg := applyCreateOverrides(&base, opts)
|
||||
|
||||
// Apply overrides on top of copied state via UpdateAgent (no
|
||||
// server-side template-parameters route, so we do two roundtrips).
|
||||
updateReq := &sdk.UpdateAgentRequest{
|
||||
Name: opts.Name,
|
||||
Description: opts.Description,
|
||||
Config: cfg,
|
||||
}
|
||||
updated, err := svc.UpdateAgent(ctx, copied.ID, updateReq)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update copied agent %s", copied.ID)
|
||||
}
|
||||
return emitAgent(fopts, updated)
|
||||
}
|
||||
|
||||
// 4. Plain create path: apply hot-path flag overrides onto base
|
||||
// (zero-valued unless --config-file supplied content).
|
||||
cfg := applyCreateOverrides(&base, opts)
|
||||
|
||||
req := &sdk.CreateAgentRequest{
|
||||
Name: opts.Name,
|
||||
Description: opts.Description,
|
||||
Config: cfg,
|
||||
}
|
||||
created, err := svc.CreateAgent(ctx, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "create agent")
|
||||
}
|
||||
return emitAgent(fopts, created)
|
||||
}
|
||||
|
||||
// agentModeValues / kbSelectionModeValues are the closed server enums for the
|
||||
// --agent-mode / --kb-selection-mode flags, sourced from the SDK enumerators so
|
||||
// the CLI can't drift from the server vocabulary.
|
||||
var (
|
||||
agentModeValues = cmdutil.EnumStrings(sdk.AllAgentModes())
|
||||
kbSelectionModeValues = cmdutil.EnumStrings(sdk.AllKBSelectionModes())
|
||||
)
|
||||
|
||||
// validateAgentModeFlags validates and canonicalises --agent-mode and
|
||||
// --kb-selection-mode in place against the SDK enums. An empty value passes
|
||||
// through (flag unset → no override). Rejecting a typo here gives a fast,
|
||||
// typed input.invalid_argument instead of a confusing server-side failure.
|
||||
// Shared by `agent create` and `agent update`.
|
||||
func validateAgentModeFlags(agentMode, kbSelectionMode *string) error {
|
||||
v, err := cmdutil.ValidateEnum("agent-mode", *agentMode, agentModeValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*agentMode = v
|
||||
v, err = cmdutil.ValidateEnum("kb-selection-mode", *kbSelectionMode, kbSelectionModeValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*kbSelectionMode = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyCreateOverrides merges hot-path flag overrides into the base config,
|
||||
// then applies the "--attach-kb implies --kb-selection-mode=selected" fallback.
|
||||
// Shared by the --from path (base=copied agent's config) and the plain
|
||||
// create path (base=zero or --config-file). Keeping both paths on one
|
||||
// helper prevents silent divergence when future flags are added.
|
||||
func applyCreateOverrides(base *sdk.AgentConfig, opts *CreateOptions) *sdk.AgentConfig {
|
||||
overrides := cmdutil.AgentConfigFlags{
|
||||
AgentMode: opts.AgentMode, AgentModeSet: opts.flags.agentModeSet,
|
||||
SystemPrompt: opts.SystemPrompt, SystemPromptSet: opts.flags.systemPromptSet,
|
||||
ModelID: opts.Model, ModelIDSet: true, // --model is required
|
||||
RerankModelID: opts.RerankModel, RerankModelIDSet: opts.flags.rerankModelSet,
|
||||
Temperature: opts.Temperature, TemperatureSet: opts.flags.temperatureSet,
|
||||
KBSelectionMode: opts.KBSelectionMode, KBSelectionModeSet: opts.flags.kbSelectionModeSet,
|
||||
KnowledgeBases: opts.KBs, KnowledgeBasesSet: opts.flags.kbsSet,
|
||||
}
|
||||
cfg := cmdutil.MergeAgentConfig(base, overrides)
|
||||
|
||||
// --attach-kb without explicit --kb-selection-mode implies "selected".
|
||||
if opts.flags.kbsSet && !opts.flags.kbSelectionModeSet && cfg.KBSelectionMode == "" {
|
||||
cfg.KBSelectionMode = "selected"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// openConfigFile returns a Reader, the detected kind ("yaml"/"json"), or
|
||||
// an error. Format is inferred from file extension; "-" defaults to YAML.
|
||||
func openConfigFile(path string) (io.Reader, string, error) {
|
||||
r, err := cmdutil.OpenInput(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
kind := "yaml"
|
||||
if strings.EqualFold(filepath.Ext(path), ".json") {
|
||||
kind = "json"
|
||||
}
|
||||
return r, kind, nil
|
||||
}
|
||||
|
||||
// emitAgent writes the Agent to stdout (bare SDK shape for --format json,
|
||||
// text KV otherwise). Shared by create and edit; defined here for proximity
|
||||
// to the create flow.
|
||||
func emitAgent(fopts *cmdutil.FormatOptions, ag *sdk.Agent) error {
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, ag, nil)
|
||||
}
|
||||
renderAgent(iostreams.IO.Out, ag)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeCreateSvc records all three SDK methods this command may invoke.
|
||||
type fakeCreateSvc struct {
|
||||
createReq *sdk.CreateAgentRequest
|
||||
createResp *sdk.Agent
|
||||
createErr error
|
||||
copySrcID string
|
||||
copyResp *sdk.Agent
|
||||
copyErr error
|
||||
updateID string
|
||||
updateReq *sdk.UpdateAgentRequest
|
||||
updateResp *sdk.Agent
|
||||
updateErr error
|
||||
updateCalled bool
|
||||
}
|
||||
|
||||
func (f *fakeCreateSvc) CreateAgent(_ context.Context, req *sdk.CreateAgentRequest) (*sdk.Agent, error) {
|
||||
f.createReq = req
|
||||
return f.createResp, f.createErr
|
||||
}
|
||||
func (f *fakeCreateSvc) CopyAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
f.copySrcID = id
|
||||
return f.copyResp, f.copyErr
|
||||
}
|
||||
func (f *fakeCreateSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error) {
|
||||
f.updateCalled = true
|
||||
f.updateID = id
|
||||
f.updateReq = req
|
||||
return f.updateResp, f.updateErr
|
||||
}
|
||||
|
||||
func TestCreate_HappyPath_MinimalRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new", Name: "Test"}}
|
||||
opts := &CreateOptions{Name: "Test", Model: "model-x"}
|
||||
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc.createReq)
|
||||
assert.Equal(t, "Test", svc.createReq.Name)
|
||||
require.NotNil(t, svc.createReq.Config)
|
||||
assert.Equal(t, "model-x", svc.createReq.Config.ModelID)
|
||||
}
|
||||
|
||||
func TestCreate_MissingName_FlagError(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs([]string{"--model", "model-x"})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
// PreRunE rejects "0 args" with our flag-error sentinel; the message
|
||||
// always carries the canonical "accepts 1 arg" phrase.
|
||||
assert.Contains(t, err.Error(), "accepts 1 arg")
|
||||
}
|
||||
|
||||
func TestCreate_MissingModel_FlagError(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs([]string{"Test"})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `required flag(s) "model" not set`)
|
||||
}
|
||||
|
||||
func TestCreate_ConfigFile_FlagsOverrideFile(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Test",
|
||||
Model: "model-x", // override file
|
||||
ConfigFileBody: bytes.NewBufferString(`{"agent_mode":"smart-reasoning","model_id":"model-y","temperature":0.5}`),
|
||||
ConfigFileKind: "json",
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.createReq.Config)
|
||||
assert.Equal(t, "smart-reasoning", svc.createReq.Config.AgentMode, "file value preserved when no flag override")
|
||||
assert.Equal(t, "model-x", svc.createReq.Config.ModelID, "flag overrides file")
|
||||
assert.InDelta(t, 0.5, svc.createReq.Config.Temperature, 0.001)
|
||||
}
|
||||
|
||||
func TestCreate_From_CopiesThenUpdates(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{
|
||||
copyResp: &sdk.Agent{ID: "ag_clone", Name: "Source", Config: &sdk.AgentConfig{ModelID: "model-y"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_clone", Name: "Renamed"},
|
||||
}
|
||||
opts := &CreateOptions{Name: "Renamed", Model: "model-x", From: "ag_source"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "ag_source", svc.copySrcID)
|
||||
require.True(t, svc.updateCalled, "must Update after Copy when overrides present")
|
||||
assert.Equal(t, "ag_clone", svc.updateID)
|
||||
assert.Equal(t, "Renamed", svc.updateReq.Name)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "model-x", svc.updateReq.Config.ModelID)
|
||||
}
|
||||
|
||||
func TestCreate_GenerateSkeleton_NoAPICall(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
opts := &CreateOptions{GenerateSkeleton: true}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Nil(t, svc.createReq, "must not call CreateAgent")
|
||||
assert.Equal(t, "", svc.copySrcID, "must not call CopyAgent")
|
||||
assert.Contains(t, out.String(), "agent_mode:", "skeleton emitted to stdout")
|
||||
}
|
||||
|
||||
func TestCreate_RepeatedKB_ImpliesSelectedMode(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Test",
|
||||
Model: "model-x",
|
||||
KBs: []string{"kb_a", "kb_b"},
|
||||
flags: createFlagSet{kbsSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a", "kb_b"}, svc.createReq.Config.KnowledgeBases)
|
||||
assert.Equal(t, "selected", svc.createReq.Config.KBSelectionMode, "passing --attach-kb implies selected mode")
|
||||
}
|
||||
|
||||
func TestCreate_SystemPromptFile_ReaderRead(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Test",
|
||||
Model: "model-x",
|
||||
SystemPromptReader: strings.NewReader("You are a helpful assistant.\n"),
|
||||
flags: createFlagSet{systemPromptSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "You are a helpful assistant.", svc.createReq.Config.SystemPrompt, "TrimSpace removes trailing newline")
|
||||
}
|
||||
|
||||
func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) {
|
||||
// Regression: with --from X and only --temperature overridden, the
|
||||
// other 33 AgentConfig fields must round-trip from the copied agent.
|
||||
// Pre-fix, runCreate built `cfg` from a zero AgentConfig{} baseline,
|
||||
// so UpdateAgent shipped temperature=0.9 plus every other field
|
||||
// zeroed — clobbering source SystemPrompt / AgentMode / KBs.
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{
|
||||
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
|
||||
ModelID: "model-y",
|
||||
SystemPrompt: "Source prompt",
|
||||
AgentMode: "smart-reasoning",
|
||||
Temperature: 0.5,
|
||||
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
|
||||
}},
|
||||
updateResp: &sdk.Agent{ID: "ag_clone"},
|
||||
}
|
||||
// Only --temperature overridden; other fields should round-trip.
|
||||
opts := &CreateOptions{
|
||||
Name: "Renamed", Model: "model-y", From: "ag_source",
|
||||
Temperature: 0.9,
|
||||
flags: createFlagSet{temperatureSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "Source prompt", svc.updateReq.Config.SystemPrompt, "source SystemPrompt must round-trip")
|
||||
assert.Equal(t, "smart-reasoning", svc.updateReq.Config.AgentMode, "source AgentMode must round-trip")
|
||||
assert.Equal(t, []string{"kb_src_a", "kb_src_b"}, svc.updateReq.Config.KnowledgeBases, "source KB list must round-trip when --attach-kb not passed")
|
||||
assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "Temperature overridden")
|
||||
}
|
||||
|
||||
func TestCreate_From_KBReplacesSourceList(t *testing.T) {
|
||||
// --attach-kb on --from REPLACES the copied agent's KB list (instead of
|
||||
// merging with it). The override semantic matches a from-scratch
|
||||
// `agent create --attach-kb a --attach-kb b`: whatever was on the source agent is
|
||||
// discarded for KBs the caller explicitly listed.
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{
|
||||
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
|
||||
ModelID: "model-y",
|
||||
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
|
||||
}},
|
||||
updateResp: &sdk.Agent{ID: "ag_clone"},
|
||||
}
|
||||
opts := &CreateOptions{
|
||||
Name: "X", Model: "model-y", From: "ag_source",
|
||||
KBs: []string{"kb_new"},
|
||||
flags: createFlagSet{kbsSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, []string{"kb_new"}, svc.updateReq.Config.KnowledgeBases, "--attach-kb replaces source KB list")
|
||||
assert.Equal(t, "selected", svc.updateReq.Config.KBSelectionMode, "--attach-kb on --from implies selected mode")
|
||||
}
|
||||
|
||||
func TestCreate_Temperature_Bounds(t *testing.T) {
|
||||
for _, badT := range []float64{-0.1, 2.1, 100.0} {
|
||||
t.Run(fmt.Sprintf("t=%g", badT), func(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs([]string{"Test", "--model", "model-x", "--temperature", fmt.Sprintf("%f", badT)})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected error for --temperature %g", badT)
|
||||
assert.Contains(t, err.Error(), "0.0..2.0")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_AgentModeValidation: an unknown --agent-mode / --kb-selection-mode
|
||||
// is rejected up front (PreRunE) with a typed input.invalid_argument (exit 5),
|
||||
// while a valid value passes the gate. The closed set is sourced from the SDK
|
||||
// enumerators, so this also guards against CLI/SDK drift.
|
||||
func TestCreate_AgentModeValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string // substring; "" means the mode gate must pass
|
||||
}{
|
||||
{"bad agent-mode", []string{"--agent-mode", "bogus"}, "agent-mode"},
|
||||
{"bad kb-selection-mode", []string{"--kb-selection-mode", "every"}, "kb-selection-mode"},
|
||||
{"valid agent-mode", []string{"--agent-mode", "smart-reasoning", "--dry-run"}, ""},
|
||||
{"valid kb-selection-mode", []string{"--kb-selection-mode", "selected", "--dry-run"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs(append([]string{"Test", "--model", "model-x"}, tc.args...))
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if tc.wantErr == "" {
|
||||
// Valid mode + --dry-run: must clear the mode gate. (--dry-run
|
||||
// short-circuits before any client call, so nil factory is fine.)
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
assert.Equal(t, 5, cmdutil.ExitCode(err))
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CopyAgent_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{copyErr: errBadHTTP404}
|
||||
opts := &CreateOptions{Name: "X", Model: "model-x", From: "ag_missing"}
|
||||
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resource.not_found")
|
||||
}
|
||||
|
||||
// errBadHTTP404 simulates the SDK's "HTTP error 404: not found" format that
|
||||
// ClassifyHTTPError parses. Defined here so create_test and edit_test/delete_test
|
||||
// can share it via package scope without spinning up an HTTP server.
|
||||
var errBadHTTP404 = &simpleErr{msg: "HTTP error 404: not found"}
|
||||
|
||||
type simpleErr struct{ msg string }
|
||||
|
||||
func (e *simpleErr) Error() string { return e.msg }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --attach-kb (renamed from --kb)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestCreate_AttachKBFlagExists asserts that `--attach-kb` is a registered flag
|
||||
// on `agent create` and that the old `--kb` flag no longer exists.
|
||||
func TestCreate_AttachKBFlagExists(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
// --attach-kb must exist
|
||||
f := cmd.Flags().Lookup("attach-kb")
|
||||
require.NotNil(t, f, "--attach-kb flag must be registered on 'agent create'")
|
||||
// bare --kb must NOT exist (renamed)
|
||||
old := cmd.Flags().Lookup("kb")
|
||||
assert.Nil(t, old, "old --kb flag must not exist on 'agent create' (renamed to --attach-kb)")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// agentDeleteFields enumerates the JSON discovery fields for `agent delete`.
|
||||
// Result payload is a tiny {id, deleted} object — mirrors `kb delete`.
|
||||
var agentDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
AgentID string
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted on success in JSON mode.
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// Delete is NOT idempotent on a missing id — it surfaces resource.not_found
|
||||
// rather than silently exiting 0. Idempotent-already-true semantics are
|
||||
// reserved for unlink-style local cleanups, not server-side resource removal.
|
||||
const agentDeleteLong = `Permanently delete a custom agent.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and --format json is
|
||||
not set. Pass -y/--yes (the global flag) to skip the prompt (required in
|
||||
agent / CI / piped contexts).
|
||||
|
||||
Typed exit codes:
|
||||
resource.not_found no agent with the given id (exit 4)
|
||||
auth.forbidden caller lacks delete permission on the agent (exit 3)
|
||||
input.confirmation_required destructive op without -y on a TTY (exit 10)
|
||||
|
||||
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
and writes input.confirmation_required to stderr. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead — the exit-10 protocol exists
|
||||
exactly to guard against unintended deletes.`
|
||||
|
||||
const agentDeleteExample = ` weknora agent delete ag_abc # interactive confirm
|
||||
weknora agent delete ag_abc -y # no prompt
|
||||
weknora agent delete ag_abc -y --format json # bare {id, deleted:true} JSON`
|
||||
|
||||
// NewCmdDelete builds `weknora agent delete <agent-id>`.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <agent-id>",
|
||||
Short: "Delete a custom agent",
|
||||
Long: agentDeleteLong,
|
||||
Example: agentDeleteExample,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.AgentID = args[0]
|
||||
opts.Yes, _ = cmd.Flags().GetBool("yes")
|
||||
if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "agent.delete",
|
||||
Args: map[string]any{
|
||||
"agent_id": opts.AgentID,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runDelete(cmd.Context(), opts, fopts, cli, f.Prompter())
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "agent.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete a custom agent",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Output: "envelope.data is {id, deleted:true}",
|
||||
Examples: []string{
|
||||
"weknora agent delete ag_abc -y",
|
||||
"weknora agent delete ag_abc -y --format json",
|
||||
},
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"agent delete is irreversible; loses the agent + its KB binding + custom skills.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "agent", opts.AgentID, "agent.delete", []string{"weknora", "agent", "delete", opts.AgentID, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.DeleteAgent(ctx, opts.AgentID); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete agent %s", opts.AgentID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.AgentID, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted agent %s\n", opts.AgentID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
type fakeDeleteSvc struct {
|
||||
gotID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDeleteSvc) DeleteAgent(_ context.Context, id string) error {
|
||||
f.gotID = id
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t) // non-TTY
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc", Yes: false},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Empty(t, svc.gotID, "must not call DeleteAgent without confirm")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_WithYes_Direct(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
require.NoError(t, runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
|
||||
))
|
||||
assert.Equal(t, "ag_abc", svc.gotID)
|
||||
assert.Contains(t, out.String(), "ag_abc")
|
||||
}
|
||||
|
||||
func TestDelete_404_PropagatesNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errBadHTTP404}
|
||||
err := runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_missing", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmYes(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p,
|
||||
))
|
||||
assert.True(t, p.Asked)
|
||||
assert.Equal(t, "ag_abc", svc.gotID)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p,
|
||||
)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.Empty(t, svc.gotID, "answer=no must not call DeleteAgent")
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestDelete_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
require.NoError(t, runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{},
|
||||
))
|
||||
assert.Contains(t, out.String(), `"id":"ag_abc"`)
|
||||
assert.Contains(t, out.String(), `"deleted":true`)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// EditService is the narrow SDK surface this command depends on. The fetch
|
||||
// half (GetAgent) is mandatory because UpdateAgent is a full PUT — without
|
||||
// the pre-fetch baseline, any field not passed as a flag would silently
|
||||
// clobber to the zero value.
|
||||
type EditService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
|
||||
// ListModels backs --model / --rerank-model id-or-name resolution so a
|
||||
// bogus name fails fast instead of clobbering config.model_id with an
|
||||
// unresolvable string (which never resolves at run time).
|
||||
ListModels(ctx context.Context) ([]sdk.Model, error)
|
||||
}
|
||||
|
||||
// EditOptions captures the surgical flag state. Both string fields and
|
||||
// reader-based file inputs are tracked alongside per-flag *Set bits in
|
||||
// editFlagSet so empty strings are distinguishable from "unset".
|
||||
type EditOptions struct {
|
||||
AgentID string
|
||||
Name string
|
||||
Description string
|
||||
Model string
|
||||
SystemPrompt string
|
||||
SystemPromptReader io.Reader
|
||||
AgentMode string
|
||||
RerankModel string
|
||||
Temperature float64
|
||||
AddKBs []string
|
||||
RemoveKBs []string
|
||||
KBSelectionMode string
|
||||
ConfigFileBody io.Reader
|
||||
ConfigFileKind string // "yaml" or "json"
|
||||
DryRun bool
|
||||
|
||||
flags editFlagSet
|
||||
}
|
||||
|
||||
// editFlagSet tracks which surgical flags the user passed. Empty-string
|
||||
// values are valid (clear semantics) so cmd.Flags().Changed() is the only
|
||||
// reliable signal of "user supplied this flag."
|
||||
type editFlagSet struct {
|
||||
nameSet bool
|
||||
descriptionSet bool
|
||||
modelSet bool
|
||||
systemPromptSet bool
|
||||
agentModeSet bool
|
||||
rerankModelSet bool
|
||||
temperatureSet bool
|
||||
addKBsSet bool
|
||||
removeKBsSet bool
|
||||
kbSelectionModeSet bool
|
||||
configFileSet bool
|
||||
}
|
||||
|
||||
const agentEditLong = `Update a custom agent's fields surgically.
|
||||
|
||||
At least one update flag is required; flags you omit preserve the current
|
||||
server-side value via fetch-then-update. Pass --description "" to clear
|
||||
the description (empty string is a valid value, not "unset").
|
||||
|
||||
KB list operations are list-shaped: --add-kb and --remove-kb are
|
||||
idempotent (re-adding an already-attached KB is silent success; removing
|
||||
an unattached KB is silent success). Passing the same id to both flags
|
||||
nets out to no-op and prints a stderr warning.
|
||||
|
||||
--config-file fully replaces the AgentConfig baseline (the same shape
|
||||
GenerateAgentSkeleton emits). Surgical flags then apply on top of that
|
||||
replaced baseline. To partially update one or two fields without
|
||||
touching the rest, use surgical flags alone — that path L-2 fetches
|
||||
current state and only mutates what's set. Precedence within a single
|
||||
invocation:
|
||||
|
||||
surgical flag > config-file value > zero value
|
||||
|
||||
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
with input.confirmation_required. Surface the prompt to the user and only
|
||||
retry with -y after explicit approval. Other failure codes: resource.not_found
|
||||
(agent id or KB id), auth.forbidden, input.invalid_argument (no flags, bad file).`
|
||||
|
||||
const agentEditExample = ` weknora agent update ag_abc --name "Renamed" -y
|
||||
weknora agent update ag_abc --description "" -y # clear description
|
||||
weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old -y
|
||||
weknora agent update ag_abc --system-prompt-file ./prompt.md -y
|
||||
weknora agent update ag_abc --config-file ./tuned.yaml --temperature 0.9 -y`
|
||||
|
||||
// NewCmdEdit builds `weknora agent update <agent-id>`.
|
||||
func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &EditOptions{}
|
||||
var systemPromptFile, configFile string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <agent-id>",
|
||||
Short: "Update a custom agent's configuration",
|
||||
Long: agentEditLong,
|
||||
Example: agentEditExample,
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.AgentID = args[0]
|
||||
opts.flags.nameSet = cmd.Flags().Changed("name")
|
||||
opts.flags.descriptionSet = cmd.Flags().Changed("description")
|
||||
opts.flags.modelSet = cmd.Flags().Changed("model")
|
||||
opts.flags.systemPromptSet = cmd.Flags().Changed("system-prompt") || cmd.Flags().Changed("system-prompt-file")
|
||||
opts.flags.agentModeSet = cmd.Flags().Changed("agent-mode")
|
||||
opts.flags.rerankModelSet = cmd.Flags().Changed("rerank-model")
|
||||
opts.flags.temperatureSet = cmd.Flags().Changed("temperature")
|
||||
opts.flags.addKBsSet = cmd.Flags().Changed("add-kb")
|
||||
opts.flags.removeKBsSet = cmd.Flags().Changed("remove-kb")
|
||||
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
|
||||
opts.flags.configFileSet = cmd.Flags().Changed("config-file")
|
||||
|
||||
// --temperature is bounded 0.0..2.0. Reject out-of-range
|
||||
// early with a typed input.invalid_argument.
|
||||
if opts.flags.temperatureSet && (opts.Temperature < 0.0 || opts.Temperature > 2.0) {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature))
|
||||
}
|
||||
|
||||
// Reject an unknown --agent-mode / --kb-selection-mode up front
|
||||
// (typed exit 5) rather than letting the server fail the update.
|
||||
if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if systemPromptFile != "" {
|
||||
r, err := cmdutil.OpenInput(systemPromptFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
|
||||
}
|
||||
opts.SystemPromptReader = r
|
||||
}
|
||||
if configFile != "" {
|
||||
r, kind, err := openConfigFile(configFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--config-file: %v", err))
|
||||
}
|
||||
opts.ConfigFileBody = r
|
||||
opts.ConfigFileKind = kind
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate "at least one update flag" before the dry-run gate so
|
||||
// --dry-run rejects identically to the live path. Same typed
|
||||
// Error as runEdit (kept there for direct-call callers).
|
||||
if !editHasAnyFlag(opts) {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "agent update requires at least one flag",
|
||||
Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file",
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
planArgs := map[string]any{"agent_id": opts.AgentID}
|
||||
if opts.flags.nameSet {
|
||||
planArgs["name"] = opts.Name
|
||||
}
|
||||
if opts.flags.descriptionSet {
|
||||
planArgs["description"] = opts.Description
|
||||
}
|
||||
if opts.flags.modelSet {
|
||||
planArgs["model"] = opts.Model
|
||||
}
|
||||
if opts.flags.agentModeSet {
|
||||
planArgs["agent_mode"] = opts.AgentMode
|
||||
}
|
||||
if opts.flags.rerankModelSet {
|
||||
planArgs["rerank_model"] = opts.RerankModel
|
||||
}
|
||||
if opts.flags.temperatureSet {
|
||||
planArgs["temperature"] = opts.Temperature
|
||||
}
|
||||
if opts.flags.addKBsSet {
|
||||
planArgs["add_kb"] = opts.AddKBs
|
||||
}
|
||||
if opts.flags.removeKBsSet {
|
||||
planArgs["remove_kb"] = opts.RemoveKBs
|
||||
}
|
||||
if opts.flags.kbSelectionModeSet {
|
||||
planArgs["kb_selection_mode"] = opts.KBSelectionMode
|
||||
}
|
||||
if opts.flags.configFileSet {
|
||||
planArgs["config_file"] = configFile
|
||||
}
|
||||
if opts.flags.systemPromptSet {
|
||||
if systemPromptFile != "" {
|
||||
planArgs["system_prompt_file"] = systemPromptFile
|
||||
} else {
|
||||
planArgs["system_prompt"] = opts.SystemPrompt
|
||||
}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(cmd, true, cmdutil.DryRunPlan{
|
||||
Action: "agent.update",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
// Build the retry command from the flags the user actually passed.
|
||||
// Include list-shaped and file-path flags so exit-10 retry_argv reproduces
|
||||
// the original update (BuildRetryArgv expands StringSlice as repeats).
|
||||
retryCmd := cmdutil.BuildRetryArgv(cmd, []string{"weknora", "agent", "update", opts.AgentID},
|
||||
"name", "description", "model", "system-prompt", "system-prompt-file",
|
||||
"agent-mode", "rerank-model", "temperature", "add-kb", "remove-kb",
|
||||
"kb-selection-mode", "config-file", "format")
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), yes, fopts.WantsJSON(), "update", "agent", opts.AgentID, "agent.update", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve --add-kb values (id or name) to canonical ids, matching
|
||||
// the --kb id-or-name policy and agent create --attach-kb. Avoids
|
||||
// silently attaching an unresolvable name as a kb_id.
|
||||
if len(opts.AddKBs) > 0 {
|
||||
resolved := make([]string, 0, len(opts.AddKBs))
|
||||
for _, raw := range opts.AddKBs {
|
||||
id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
resolved = append(resolved, id)
|
||||
}
|
||||
opts.AddKBs = resolved
|
||||
}
|
||||
return runEdit(cmd.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
|
||||
// Surgical flags
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "New agent name")
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", `New description (use "" to clear)`)
|
||||
cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id")
|
||||
cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)")
|
||||
cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin")
|
||||
cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file")
|
||||
cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | "))
|
||||
cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id")
|
||||
cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)")
|
||||
cmd.Flags().StringSliceVar(&opts.AddKBs, "add-kb", nil, "Attach knowledge base id (repeatable, idempotent)")
|
||||
cmd.Flags().StringSliceVar(&opts.RemoveKBs, "remove-kb", nil, "Detach knowledge base id (repeatable, idempotent)")
|
||||
cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | "))
|
||||
|
||||
// Full-replace
|
||||
cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (REPLACES current config baseline; surgical flags then apply on top)")
|
||||
|
||||
cmdutil.AddFormatFlag(cmd, agentViewFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "agent.update")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "surgically update a custom agent's configuration",
|
||||
RequiredFlags: []string{"<agent-id> (positional)", "at least one update flag (--name, --add-kb, etc.)"},
|
||||
Examples: []string{
|
||||
"weknora agent update ag_abc --name \"Renamed\"",
|
||||
"weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old",
|
||||
"weknora agent update ag_abc --config-file ./tuned.yaml",
|
||||
},
|
||||
Output: "envelope.data is the updated Agent object (id, name, config) after the update is applied",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"agent update overwrites config; fetch-then-update protects unmentioned fields, but bad input still saved.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// editHasAnyFlag reports whether opts carries at least one surgical update
|
||||
// signal. Required-flag validation lives in runEdit (not PreRunE) so unit
|
||||
// tests can invoke runEdit with a hand-built EditOptions directly.
|
||||
func editHasAnyFlag(opts *EditOptions) bool {
|
||||
fl := opts.flags
|
||||
return fl.nameSet || fl.descriptionSet || fl.modelSet || fl.systemPromptSet ||
|
||||
fl.agentModeSet || fl.rerankModelSet || fl.temperatureSet ||
|
||||
fl.addKBsSet || fl.removeKBsSet || fl.kbSelectionModeSet || fl.configFileSet
|
||||
}
|
||||
|
||||
func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService) error {
|
||||
if !editHasAnyFlag(opts) {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "agent update requires at least one flag",
|
||||
Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file",
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch-then-update so omitted fields round-trip unchanged through
|
||||
// the full PUT body.
|
||||
current, err := svc.GetAgent(ctx, opts.AgentID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch agent %s", opts.AgentID)
|
||||
}
|
||||
|
||||
// Build base config: server state, then overlay --config-file (if any).
|
||||
base := sdk.AgentConfig{}
|
||||
if current.Config != nil {
|
||||
base = *current.Config
|
||||
}
|
||||
if opts.ConfigFileBody != nil {
|
||||
parsed, err := cmdutil.LoadAgentConfig(opts.ConfigFileBody, opts.ConfigFileKind)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, err.Error())
|
||||
}
|
||||
base = *parsed
|
||||
}
|
||||
|
||||
// Resolve --system-prompt-file before flag overlay.
|
||||
if opts.SystemPromptReader != nil {
|
||||
body, err := io.ReadAll(opts.SystemPromptReader)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file read: %v", err))
|
||||
}
|
||||
opts.SystemPrompt = strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// Resolve --model / --rerank-model (id or name) and validate they exist,
|
||||
// mirroring agent create. Without this a bogus name is stored verbatim as
|
||||
// config.model_id and the agent silently never resolves at run time.
|
||||
if opts.flags.modelSet {
|
||||
if opts.Model, err = cmdutil.ResolveModelRef(ctx, svc, opts.Model, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.flags.rerankModelSet {
|
||||
if opts.RerankModel, err = cmdutil.ResolveModelRef(ctx, svc, opts.RerankModel, "Rerank"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Compute KB list from current + add/remove with a stderr warning when
|
||||
// the same id appears in both flags (net no-op, still idempotent).
|
||||
kbs := computeKBList(base.KnowledgeBases, opts.AddKBs, opts.RemoveKBs)
|
||||
|
||||
overrides := cmdutil.AgentConfigFlags{
|
||||
AgentMode: opts.AgentMode, AgentModeSet: opts.flags.agentModeSet,
|
||||
SystemPrompt: opts.SystemPrompt, SystemPromptSet: opts.flags.systemPromptSet,
|
||||
ModelID: opts.Model, ModelIDSet: opts.flags.modelSet,
|
||||
RerankModelID: opts.RerankModel, RerankModelIDSet: opts.flags.rerankModelSet,
|
||||
Temperature: opts.Temperature, TemperatureSet: opts.flags.temperatureSet,
|
||||
KBSelectionMode: opts.KBSelectionMode, KBSelectionModeSet: opts.flags.kbSelectionModeSet,
|
||||
// KB list is always replaced (its add/remove was already merged
|
||||
// into kbs); we only signal "set" when the user actually touched
|
||||
// the list so a plain --name edit doesn't churn the field.
|
||||
KnowledgeBases: kbs,
|
||||
KnowledgeBasesSet: opts.flags.addKBsSet || opts.flags.removeKBsSet,
|
||||
}
|
||||
cfg := cmdutil.MergeAgentConfig(&base, overrides)
|
||||
|
||||
// Build the full PUT body. Name/Description default to the current
|
||||
// server values so the surgical-flag-only path preserves them.
|
||||
req := &sdk.UpdateAgentRequest{
|
||||
Name: current.Name,
|
||||
Description: current.Description,
|
||||
Config: cfg,
|
||||
}
|
||||
if opts.flags.nameSet {
|
||||
req.Name = opts.Name
|
||||
}
|
||||
if opts.flags.descriptionSet {
|
||||
req.Description = opts.Description
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateAgent(ctx, opts.AgentID, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update agent %s", opts.AgentID)
|
||||
}
|
||||
return emitAgent(fopts, updated)
|
||||
}
|
||||
|
||||
// computeKBList applies --add-kb / --remove-kb to current with idempotent
|
||||
// semantics. Ids present in both add and remove cancel out and surface a
|
||||
// stderr warning so users notice the conflict but don't see a hard error.
|
||||
// Stderr is the right channel here (not stdout) because callers piping
|
||||
// --format json | jq would otherwise see corrupted JSON.
|
||||
func computeKBList(current, add, remove []string) []string {
|
||||
// Detect ids in both add and remove; they net out to no-op and are
|
||||
// excluded from both operations.
|
||||
canceledSet := map[string]struct{}{}
|
||||
addSeen := map[string]struct{}{}
|
||||
for _, id := range add {
|
||||
addSeen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range remove {
|
||||
if _, both := addSeen[id]; both {
|
||||
canceledSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(canceledSet) > 0 {
|
||||
canceled := make([]string, 0, len(canceledSet))
|
||||
for id := range canceledSet {
|
||||
canceled = append(canceled, id)
|
||||
}
|
||||
// Sort for deterministic test output; map iteration is random.
|
||||
sort.Strings(canceled)
|
||||
fmt.Fprintf(iostreams.IO.Err, "warning: --add-kb and --remove-kb cancel out for: %s\n", strings.Join(canceled, ", "))
|
||||
}
|
||||
|
||||
// Compute effective remove set (excluding canceled).
|
||||
removeEff := map[string]struct{}{}
|
||||
for _, id := range remove {
|
||||
if _, c := canceledSet[id]; c {
|
||||
continue
|
||||
}
|
||||
removeEff[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Filter removals out of the current list (idempotent: unattached id
|
||||
// simply isn't in current).
|
||||
out := make([]string, 0, len(current))
|
||||
for _, id := range current {
|
||||
if _, drop := removeEff[id]; drop {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
// Append any add ids not already present (idempotent: already-attached
|
||||
// id silently de-dupes).
|
||||
present := map[string]struct{}{}
|
||||
for _, id := range out {
|
||||
present[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, c := canceledSet[id]; c {
|
||||
continue
|
||||
}
|
||||
if _, dup := present[id]; dup {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
present[id] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// agentDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call.
|
||||
func agentDryRunFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentEdit_DryRun_RejectsNoFlag: --dry-run must reject the same
|
||||
// "no update flag supplied" invocation the live path rejects. Before the
|
||||
// fix the editHasAnyFlag check lived in runEdit() (live-only), reached only
|
||||
// after HandleDryRun early-exited.
|
||||
func TestAgentEdit_DryRun_RejectsNoFlag(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := agentDryRunFactory(t)
|
||||
root := withRootHarnessAgent(NewCmdEdit(f), "ag_abc", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject identically to live path")
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeEditSvc scripts GetAgent (fetch baseline) + UpdateAgent (apply
|
||||
// surgical overlays). updateCalls lets tests verify that no-flag invocations
|
||||
// don't reach the wire.
|
||||
type fakeEditSvc struct {
|
||||
getResp *sdk.Agent
|
||||
getErr error
|
||||
updateReq *sdk.UpdateAgentRequest
|
||||
updateID string
|
||||
updateResp *sdk.Agent
|
||||
updateErr error
|
||||
updateCalls int
|
||||
models []sdk.Model
|
||||
modelsErr error
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
|
||||
return f.getResp, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error) {
|
||||
f.updateReq = req
|
||||
f.updateID = id
|
||||
f.updateCalls++
|
||||
return f.updateResp, f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) ListModels(_ context.Context) ([]sdk.Model, error) {
|
||||
return f.models, f.modelsErr
|
||||
}
|
||||
|
||||
func TestEdit_ModelName_ResolvedToID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
|
||||
}
|
||||
opts := &EditOptions{AgentID: "ag_abc", Model: "good-llm", flags: editFlagSet{modelSet: true}}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "m-real", svc.updateReq.Config.ModelID, "--model name must resolve to the model id")
|
||||
}
|
||||
|
||||
func TestEdit_BogusModelName_RejectedNoWrite(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
|
||||
}
|
||||
opts := &EditOptions{AgentID: "ag_abc", Model: "totally-bogus-model-xyz", flags: editFlagSet{modelSet: true}}
|
||||
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err, "a --model name matching no model must fail")
|
||||
var e *cmdutil.Error
|
||||
require.ErrorAs(t, err, &e)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, e.Code)
|
||||
assert.Equal(t, 0, svc.updateCalls, "must not write an agent with an unresolvable model")
|
||||
}
|
||||
|
||||
func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{
|
||||
ID: "ag_abc", Name: "Original", Description: "Keep me",
|
||||
Config: &sdk.AgentConfig{ModelID: "model-x", Temperature: 0.7, KnowledgeBases: []string{"kb_a"}},
|
||||
},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
}
|
||||
// Only --description passed; everything else should round-trip.
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
Description: "Updated",
|
||||
flags: editFlagSet{descriptionSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
assert.Equal(t, "Original", svc.updateReq.Name, "Name must round-trip unchanged")
|
||||
assert.Equal(t, "Updated", svc.updateReq.Description)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "model-x", svc.updateReq.Config.ModelID, "ModelID must round-trip")
|
||||
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "KBs must round-trip")
|
||||
assert.InDelta(t, 0.7, svc.updateReq.Config.Temperature, 0.001)
|
||||
}
|
||||
|
||||
func TestEdit_AddRemoveKB_SameID_NetNoOpWithWarning(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
AddKBs: []string{"kb_b"},
|
||||
RemoveKBs: []string{"kb_b"},
|
||||
flags: editFlagSet{addKBsSet: true, removeKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "net no-op preserves original list")
|
||||
assert.Contains(t, errBuf.String(), "cancel out", "warning emitted to stderr")
|
||||
}
|
||||
|
||||
func TestEdit_NoFlags_InvalidArgument(t *testing.T) {
|
||||
svc := &fakeEditSvc{}
|
||||
err := runEdit(context.Background(), &EditOptions{AgentID: "ag_abc"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Equal(t, 0, svc.updateCalls, "must not call UpdateAgent")
|
||||
}
|
||||
|
||||
func TestEdit_AddKB_AlreadyAttached_Silent(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a", "kb_b"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
AddKBs: []string{"kb_a"}, // already attached
|
||||
flags: editFlagSet{addKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a", "kb_b"}, svc.updateReq.Config.KnowledgeBases, "no duplicate")
|
||||
assert.NotContains(t, errBuf.String(), "warning", "already-attached is silent success")
|
||||
}
|
||||
|
||||
func TestEdit_RemoveKB_Unattached_Silent(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
RemoveKBs: []string{"kb_zzz"},
|
||||
flags: editFlagSet{removeKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases)
|
||||
assert.NotContains(t, errBuf.String(), "warning")
|
||||
}
|
||||
|
||||
func TestEdit_ClearDescription_EmptyString(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Name: "X", Description: "old", Config: &sdk.AgentConfig{ModelID: "m"}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
Description: "",
|
||||
flags: editFlagSet{descriptionSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "", svc.updateReq.Description, "explicit empty must clear server-side")
|
||||
assert.Equal(t, "X", svc.updateReq.Name, "Name round-trip unchanged")
|
||||
}
|
||||
|
||||
func TestEdit_ConfigFile_OverridesByFlag(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{
|
||||
Name: "X",
|
||||
Config: &sdk.AgentConfig{ModelID: "old-model", Temperature: 0.1},
|
||||
},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
Temperature: 0.9,
|
||||
ConfigFileBody: bytes.NewBufferString(`{"temperature":0.5,"model_id":"file-model"}`),
|
||||
ConfigFileKind: "json",
|
||||
flags: editFlagSet{temperatureSet: true, configFileSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "file-model", svc.updateReq.Config.ModelID, "file overrides current state")
|
||||
assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "flag overrides file")
|
||||
}
|
||||
|
||||
// TestEdit_ConfigFile_FullReplacesBaseline pins the documented behavior:
|
||||
// --config-file fully replaces the AgentConfig baseline; current-server
|
||||
// fields not mentioned in the file are zeroed. The Long help directs
|
||||
// users to surgical flags when they want a partial update.
|
||||
func TestEdit_ConfigFile_FullReplacesBaseline(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{
|
||||
Name: "X",
|
||||
Config: &sdk.AgentConfig{
|
||||
SystemPrompt: "Existing prompt",
|
||||
ModelID: "old-model",
|
||||
Temperature: 0.1,
|
||||
AgentMode: "smart-reasoning",
|
||||
KnowledgeBases: []string{"kb_existing"},
|
||||
},
|
||||
},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
ConfigFileBody: bytes.NewBufferString(`{"model_id":"file-only"}`),
|
||||
ConfigFileKind: "json",
|
||||
flags: editFlagSet{configFileSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "file-only", svc.updateReq.Config.ModelID, "file's model_id applied")
|
||||
assert.Equal(t, "", svc.updateReq.Config.SystemPrompt, "file fully replaces baseline; unset fields are zeroed")
|
||||
assert.InDelta(t, 0.0, svc.updateReq.Config.Temperature, 0.001, "unset fields zeroed")
|
||||
assert.Equal(t, "", svc.updateReq.Config.AgentMode, "unset fields zeroed")
|
||||
assert.Empty(t, svc.updateReq.Config.KnowledgeBases, "unset fields zeroed")
|
||||
}
|
||||
|
||||
func TestEdit_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{getErr: errBadHTTP404}
|
||||
opts := &EditOptions{AgentID: "ag_missing", Name: "x", flags: editFlagSet{nameSet: true}}
|
||||
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resource.not_found")
|
||||
}
|
||||
|
||||
func TestEdit_AddKB_AppendsToExisting(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
AddKBs: []string{"kb_b", "kb_c"},
|
||||
flags: editFlagSet{addKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a", "kb_b", "kb_c"}, svc.updateReq.Config.KnowledgeBases)
|
||||
}
|
||||
|
||||
func TestEdit_Temperature_Bounds(t *testing.T) {
|
||||
for _, badT := range []float64{-0.1, 2.1, 100.0} {
|
||||
t.Run(fmt.Sprintf("t=%g", badT), func(t *testing.T) {
|
||||
cmd := NewCmdEdit(nil)
|
||||
cmd.SetArgs([]string{"ag_abc", "--temperature", fmt.Sprintf("%f", badT)})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected error for --temperature %g", badT)
|
||||
assert.Contains(t, err.Error(), "0.0..2.0")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdit_SystemPromptFile(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{ModelID: "m"}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
SystemPromptReader: strings.NewReader("new prompt\n"),
|
||||
flags: editFlagSet{systemPromptSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "new prompt", svc.updateReq.Config.SystemPrompt)
|
||||
}
|
||||
|
||||
// withRootHarnessAgent wraps `weknora agent update ...` under a synthetic root
|
||||
// cmd that registers the global persistent flags (mirrors addGlobalFlags in
|
||||
// cmd/root.go).
|
||||
func withRootHarnessAgent(edit *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "Output format: text | json | ndjson")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
ag := &cobra.Command{Use: "agent"}
|
||||
ag.AddCommand(edit)
|
||||
root.AddCommand(ag)
|
||||
root.SetArgs(append([]string{"agent", "update"}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestAgentEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON
|
||||
// mode), agent update returns input.confirmation_required (exit 10).
|
||||
func TestAgentEdit_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarnessAgent(NewCmdEdit(f), "ag_abc", "--name", "Renamed", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
|
||||
// retry argv must include -y and the agent id
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
assert.Contains(t, ce.RetryArgv, "ag_abc")
|
||||
}
|
||||
|
||||
// TestAgentEdit_RetryArgvPreservesAddKB is a regression for #2597: exit-10
|
||||
// retry_argv must keep --add-kb / --remove-kb / --config-file so re-running
|
||||
// after human approval reproduces the original update.
|
||||
func TestAgentEdit_RetryArgvPreservesAddKB(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
// Avoid --config-file / --system-prompt-file here: PreRunE opens paths and
|
||||
// would fail before ConfirmWrite. Those flags still go through the same
|
||||
// BuildRetryArgv scalar path covered in cmdutil tests.
|
||||
root := withRootHarnessAgent(NewCmdEdit(f),
|
||||
"ag_abc", "--add-kb", "kb_new", "--remove-kb", "kb_old", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
// pflag Visit is lexicographical among changed flags.
|
||||
assert.Equal(t, []string{
|
||||
"weknora", "agent", "update", "ag_abc",
|
||||
"--add-kb", "kb_new",
|
||||
"--format", "json",
|
||||
"--remove-kb", "kb_old",
|
||||
"-y",
|
||||
}, ce.RetryArgv)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// agentListFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `agent list`. Mirrors the json tags on sdk.Agent - nested Config is
|
||||
// omitted because its sub-fields make filtering noisy (use `--jq` instead).
|
||||
var agentListFields = []string{
|
||||
"id", "name", "description", "avatar",
|
||||
"is_builtin", "tenant_id", "created_by",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
ListAgents(ctx context.Context) ([]sdk.Agent, error)
|
||||
}
|
||||
|
||||
// ListOptions captures `agent list` filter flag state.
|
||||
type ListOptions struct {
|
||||
// Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit.
|
||||
// The agent list SDK is unpaginated; --all-pages is intentionally not
|
||||
// exposed because it would be a no-op.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora agent list`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List custom agents visible to the active tenant",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate static input before building the client so a bad --limit
|
||||
// returns input.invalid_argument (exit 5), not an auth error (exit 3).
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
|
||||
cmdutil.AddFormatFlag(cmd, agentListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List custom agents visible to the active tenant. The SDK returns all agents in one call (no server-side pagination); meta.count reflects the full tenant set, --limit caps client-side.",
|
||||
Examples: []string{"weknora agent list --format json", "weknora agent list --limit 10 --format json"},
|
||||
Output: "envelope.data is an array of Agent objects with id, name, is_builtin; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit. Called from RunE before the client is built
|
||||
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
|
||||
// for direct callers; idempotent and nil-safe.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 10000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if opts == nil {
|
||||
opts = &ListOptions{}
|
||||
}
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := svc.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list agents")
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Agent{} // ensure JSON [] not null
|
||||
}
|
||||
// Default sort: updated_at desc - most recently-edited agents surface
|
||||
// first. Mirrors kb list / doc list behavior.
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].UpdatedAt.After(items[j].UpdatedAt)
|
||||
})
|
||||
// The agent list SDK is unpaginated — it returns every agent in one
|
||||
// call — so the CLI holds the true total and can tell the caller
|
||||
// whether the client-side --limit dropped any. total_count is the full
|
||||
// count; has_more flags that --limit truncated it (raise --limit to get
|
||||
// the rest — there is no server cursor to continue with).
|
||||
total := len(items)
|
||||
truncated := false
|
||||
if opts.Limit > 0 && len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no agents)")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tBUILTIN\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, a := range items {
|
||||
name := text.Truncate(40, a.Name)
|
||||
builtin := "-"
|
||||
if a.IsBuiltin {
|
||||
builtin = "yes"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, name, builtin, text.FuzzyAgo(now, a.UpdatedAt))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeListSvc struct {
|
||||
items []sdk.Agent
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListAgents(_ context.Context) ([]sdk.Agent, error) {
|
||||
return f.items, f.err
|
||||
}
|
||||
|
||||
func TestList_Empty_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no agents)") {
|
||||
t.Errorf("expected '(no agents)', got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Empty_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Agent `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Error("envelope.ok=false")
|
||||
}
|
||||
if len(env.Data) != 0 {
|
||||
t.Errorf("expected empty data, got %d items", len(env.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NonEmpty_Text_RendersColumns(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.Agent{
|
||||
{ID: "ag_a", Name: "Research", IsBuiltin: true, UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
{ID: "ag_b", Name: "Triage", UpdatedAt: now.Add(-3 * 24 * time.Hour)},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, w := range []string{"ID", "NAME", "BUILTIN", "ag_a", "Research", "yes", "ag_b", "Triage"} {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("output missing %q in:\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.Agent{
|
||||
{ID: "ag_old", Name: "old", UpdatedAt: now.Add(-7 * 24 * time.Hour)},
|
||||
{ID: "ag_new", Name: "new", UpdatedAt: now},
|
||||
{ID: "ag_mid", Name: "mid", UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Agent `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
wantOrder := []string{"ag_new", "ag_mid", "ag_old"}
|
||||
for i, w := range wantOrder {
|
||||
if got[i].ID != w {
|
||||
t.Errorf("position %d: got %s, want %s (updated_at desc)", i, got[i].ID, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_JSON_JQProjection(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
items := []sdk.Agent{
|
||||
{ID: "ag_x", Name: "Foo", Description: "long description"},
|
||||
}
|
||||
// --jq projects from the envelope; .data[] | ... extracts from the array inside envelope.
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data[] | {id, name}"}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, has := got["description"]; has {
|
||||
t.Errorf("description should be filtered out: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// makeAgents returns N Agents with distinct IDs and descending UpdatedAt.
|
||||
func makeAgents(n int) []sdk.Agent {
|
||||
base := time.Now()
|
||||
out := make([]sdk.Agent, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = sdk.Agent{
|
||||
ID: fmt.Sprintf("ag_%02d", i),
|
||||
Name: fmt.Sprintf("name-%02d", i),
|
||||
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestList_Limit_CapsResults(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := strings.Count(out.String(), `"id":"ag_`)
|
||||
if got != 5 {
|
||||
t.Errorf("--limit 5 must slice 20 down to 5; got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_Truncation_SignalsHasMoreAndTotal pins that --limit truncation
|
||||
// tells the agent it did not get everything (has_more + total_count).
|
||||
// Regression: agent list silently dropped agents past --limit with no signal.
|
||||
func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"has_more":true`) {
|
||||
t.Errorf("truncated list must set has_more:true; got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"total_count":20`) {
|
||||
t.Errorf("truncated list must report total_count:20; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NoTruncation_OmitsHasMore(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(3)}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), `"has_more"`) {
|
||||
t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"total_count":3`) {
|
||||
t.Errorf("list must report total_count:3; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Limit_Zero_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(7)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --limit 0")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Limit_Negative_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeAgents(2)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --limit")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// AgentStatusResult is the shallow health snapshot for `agent status <id>`.
|
||||
//
|
||||
// One HTTP call: reachable + model_id. For downstream KB reachability
|
||||
// verification use 'agent check <id>' (1 + N HTTP).
|
||||
//
|
||||
// model_reachable is intentionally OMITTED: the server has no per-agent
|
||||
// test endpoint, so we cannot verify model reachability from the agent
|
||||
// resource alone.
|
||||
type AgentStatusResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStatusService is the narrow SDK surface needed for agent status.
|
||||
type AgentStatusService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
}
|
||||
|
||||
var agentStatusFields = []string{"id", "reachable", "model_id"}
|
||||
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status <agent-id>",
|
||||
Short: "Show health status of a custom agent",
|
||||
Long: `Shallow health snapshot for an agent (1 HTTP call).
|
||||
|
||||
Returns: reachable / model_id.
|
||||
|
||||
For downstream KB reachability verification use 'weknora agent check <id>'
|
||||
(active verification, 1 + N HTTP). For full agent config / metadata use
|
||||
'weknora agent view <id>'.`,
|
||||
Example: ` weknora agent status ag_abc
|
||||
weknora agent status ag_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := runAgentStatus(c.Context(), cli, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitAgentStatus(res, fopts, iostreams.IO.Out)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentStatusFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "shallow health probe of a custom agent: reachability without kb_scope verification",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Examples: []string{"weknora agent status agent_abc"},
|
||||
Output: "envelope.data is {id, reachable, ...}; use `agent check` for deep kb_scope verification",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runAgentStatus is the testable core. Never errors for "agent not
|
||||
// reachable" — Reachable=false carries that signal.
|
||||
func runAgentStatus(ctx context.Context, svc AgentStatusService, id string) (*AgentStatusResult, error) {
|
||||
a, err := svc.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
return &AgentStatusResult{ID: id, Reachable: false}, nil
|
||||
}
|
||||
res := &AgentStatusResult{ID: a.ID, Reachable: true}
|
||||
if a.Config != nil {
|
||||
res.ModelID = a.Config.ModelID
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// emitAgentStatus renders res per --format. Mirrors emitStatus pattern from
|
||||
// cli/cmd/kb/status.go (C3).
|
||||
func emitAgentStatus(res *AgentStatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, res, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeAgentStatusText(w, res)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for agent status", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentStatusText(w io.Writer, res *AgentStatusResult) error {
|
||||
fmt.Fprintf(w, "ID: %s\n", res.ID)
|
||||
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
fmt.Fprintf(w, "Model: %s\n", res.ModelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies AgentStatusService.
|
||||
var _ AgentStatusService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,108 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeAgentStatusSvc struct {
|
||||
agent *sdk.Agent
|
||||
agentErr error
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatusSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
if f.agentErr != nil {
|
||||
return nil, f.agentErr
|
||||
}
|
||||
return f.agent, nil
|
||||
}
|
||||
|
||||
func TestRunAgentStatus_ShallowFields(t *testing.T) {
|
||||
svc := &fakeAgentStatusSvc{agent: &sdk.Agent{
|
||||
ID: "ag_x",
|
||||
Config: &sdk.AgentConfig{
|
||||
ModelID: "m_x",
|
||||
KnowledgeBases: []string{"kb_a", "kb_b"},
|
||||
},
|
||||
}}
|
||||
res, err := runAgentStatus(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if res.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", res.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentStatus_Unreachable(t *testing.T) {
|
||||
svc := &fakeAgentStatusSvc{agentErr: fmt.Errorf("404")}
|
||||
res, err := runAgentStatus(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if res.Reachable {
|
||||
t.Error("Reachable=true on 404; want false")
|
||||
}
|
||||
if res.ID != "ag_x" {
|
||||
t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentStatus_NilConfig(t *testing.T) {
|
||||
// Defensive: Agent.Config is a pointer; nil should not panic
|
||||
svc := &fakeAgentStatusSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}}
|
||||
res, err := runAgentStatus(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true on nil config")
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
t.Errorf("ModelID=%q, want empty (no config)", res.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentStatus_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := emitAgentStatus(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data AgentStatusResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if got.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", got.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentStatus_Text(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
if err := emitAgentStatus(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
for _, want := range []string{"ag_x", "m_x", "true"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// promptPreviewWidth caps inline KV row prompt previews. Multi-line prompts
|
||||
// collapse to one line via text.OneLine; the Templates section gets the
|
||||
// full multi-line treatment instead.
|
||||
const promptPreviewWidth = 80
|
||||
|
||||
// agentViewFields enumerates the top-level Agent keys surfaced in `--help`
|
||||
// as a hint for `--jq` projection. Nested AgentConfig fields are reachable
|
||||
// via `--jq '.config.system_prompt'` or by selecting `config` whole and
|
||||
// post-processing.
|
||||
var agentViewFields = []string{
|
||||
"id", "name", "description", "avatar",
|
||||
"is_builtin", "tenant_id", "created_by", "config",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetAgent(ctx context.Context, agentID string) (*sdk.Agent, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora agent view <agent-id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <agent-id>",
|
||||
Short: "Show a custom agent's configuration",
|
||||
Long: `Renders the agent's metadata and full AgentConfig as grouped KV
|
||||
sections (Identity / LLM / KB attachment / Retrieval / Query rewrite /
|
||||
Tools / FAQ / Web search / Multi-turn / Fallback / Templates). Zero-value
|
||||
fields are omitted; sections with no set fields are suppressed entirely.
|
||||
|
||||
Pass --format json for the bare SDK Agent object (config nested, not flattened).
|
||||
Use --jq to project specific fields or reach into nested config.`,
|
||||
Example: ` weknora agent view ag_abc
|
||||
weknora agent view ag_abc --format json --jq '{id, name, config}' # top-level projection
|
||||
weknora agent view ag_abc --format json --jq '.config.system_prompt'`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentViewFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one custom agent's full configuration by id",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Examples: []string{"weknora agent view agent_abc"},
|
||||
Output: "envelope.data is the custom agent (id, name, description, is_builtin, and a nested config object holding model/kb_scope/...)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, fopts *cmdutil.FormatOptions, svc ViewService, agentID string) error {
|
||||
a, err := svc.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch agent %s", agentID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, a, nil)
|
||||
}
|
||||
renderAgent(iostreams.IO.Out, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAgent prints a single agent in human-readable form, grouped into
|
||||
// 10 presentation sections. Zero-value fields are omitted; a section
|
||||
// header prints only when at least one of its fields is set. Group
|
||||
// labels and order are pinned by snapshot tests so future drift surfaces
|
||||
// as test failure rather than silent divergence.
|
||||
func renderAgent(w io.Writer, a *sdk.Agent) {
|
||||
// Identity is always rendered — id/name/created_at/updated_at are
|
||||
// never meaningfully empty for a fetched Agent.
|
||||
fmt.Fprintln(w, "Identity:")
|
||||
fmt.Fprintf(w, " ID: %s\n", a.ID)
|
||||
fmt.Fprintf(w, " Name: %s\n", a.Name)
|
||||
if a.Description != "" {
|
||||
fmt.Fprintf(w, " Description: %s\n", a.Description)
|
||||
}
|
||||
if a.IsBuiltin {
|
||||
fmt.Fprintln(w, " Builtin: yes")
|
||||
}
|
||||
if a.CreatedBy != "" {
|
||||
fmt.Fprintf(w, " Created by: %s\n", a.CreatedBy)
|
||||
}
|
||||
if a.TenantID != 0 {
|
||||
fmt.Fprintf(w, " Tenant ID: %d\n", a.TenantID)
|
||||
}
|
||||
fmt.Fprintf(w, " Created at: %s\n", a.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(w, " Updated at: %s\n", a.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if a.Config == nil {
|
||||
return
|
||||
}
|
||||
c := a.Config
|
||||
|
||||
// Each group is rendered via a tiny helper: collect its set rows
|
||||
// upfront, suppress the whole section if empty. Avoids the
|
||||
// "header printed but no body" failure mode that plagues naive
|
||||
// conditional rendering.
|
||||
type row struct{ k, v string }
|
||||
emit := func(label string, rows []row) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "%s:\n", label)
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(w, " %-32s %s\n", r.k+":", r.v)
|
||||
}
|
||||
}
|
||||
|
||||
// LLM
|
||||
llm := []row{}
|
||||
if c.ModelID != "" {
|
||||
llm = append(llm, row{"Model ID", c.ModelID})
|
||||
}
|
||||
if c.RerankModelID != "" {
|
||||
llm = append(llm, row{"Rerank model ID", c.RerankModelID})
|
||||
}
|
||||
if c.Temperature != 0 {
|
||||
llm = append(llm, row{"Temperature", fmt.Sprintf("%g", c.Temperature)})
|
||||
}
|
||||
if c.MaxCompletionTokens != 0 {
|
||||
llm = append(llm, row{"Max completion tokens", fmt.Sprintf("%d", c.MaxCompletionTokens)})
|
||||
}
|
||||
if c.MaxIterations < 0 {
|
||||
llm = append(llm, row{"Max iterations", "unlimited"})
|
||||
} else if c.MaxIterations != 0 {
|
||||
llm = append(llm, row{"Max iterations", fmt.Sprintf("%d", c.MaxIterations)})
|
||||
}
|
||||
if c.AgentMode != "" {
|
||||
llm = append(llm, row{"Mode", c.AgentMode})
|
||||
}
|
||||
emit("LLM", llm)
|
||||
|
||||
// KB attachment
|
||||
kb := []row{}
|
||||
if c.KBSelectionMode != "" {
|
||||
kb = append(kb, row{"KB selection mode", c.KBSelectionMode})
|
||||
}
|
||||
if len(c.KnowledgeBases) > 0 {
|
||||
kb = append(kb, row{"Knowledge bases", strings.Join(c.KnowledgeBases, ", ")})
|
||||
}
|
||||
emit("KB attachment", kb)
|
||||
|
||||
// Retrieval
|
||||
retr := []row{}
|
||||
if c.EmbeddingTopK != 0 {
|
||||
retr = append(retr, row{"Embedding top K", fmt.Sprintf("%d", c.EmbeddingTopK)})
|
||||
}
|
||||
if c.KeywordThreshold != 0 {
|
||||
retr = append(retr, row{"Keyword threshold", fmt.Sprintf("%g", c.KeywordThreshold)})
|
||||
}
|
||||
if c.VectorThreshold != 0 {
|
||||
retr = append(retr, row{"Vector threshold", fmt.Sprintf("%g", c.VectorThreshold)})
|
||||
}
|
||||
if c.RerankTopK != 0 {
|
||||
retr = append(retr, row{"Rerank top K", fmt.Sprintf("%d", c.RerankTopK)})
|
||||
}
|
||||
if c.RerankThreshold != 0 {
|
||||
retr = append(retr, row{"Rerank threshold", fmt.Sprintf("%g", c.RerankThreshold)})
|
||||
}
|
||||
emit("Retrieval", retr)
|
||||
|
||||
// Query rewrite
|
||||
qr := []row{}
|
||||
if c.EnableQueryExpansion {
|
||||
qr = append(qr, row{"Query expansion", "enabled"})
|
||||
}
|
||||
if c.EnableRewrite {
|
||||
qr = append(qr, row{"Rewrite", "enabled"})
|
||||
}
|
||||
if c.QueryUnderstandModelID != "" {
|
||||
qr = append(qr, row{"Query understand model ID", c.QueryUnderstandModelID})
|
||||
}
|
||||
if c.RewritePromptSystem != "" {
|
||||
qr = append(qr, row{"Rewrite prompt (system)", text.OneLine(promptPreviewWidth, c.RewritePromptSystem)})
|
||||
}
|
||||
if c.RewritePromptUser != "" {
|
||||
qr = append(qr, row{"Rewrite prompt (user)", text.OneLine(promptPreviewWidth, c.RewritePromptUser)})
|
||||
}
|
||||
emit("Query rewrite", qr)
|
||||
|
||||
// Tools
|
||||
tools := []row{}
|
||||
if len(c.AllowedTools) > 0 {
|
||||
tools = append(tools, row{"Allowed tools", strings.Join(c.AllowedTools, ", ")})
|
||||
}
|
||||
if c.MCPSelectionMode != "" {
|
||||
tools = append(tools, row{"MCP selection mode", c.MCPSelectionMode})
|
||||
}
|
||||
if len(c.MCPServices) > 0 {
|
||||
tools = append(tools, row{"MCP services", strings.Join(c.MCPServices, ", ")})
|
||||
}
|
||||
if len(c.SupportedFileTypes) > 0 {
|
||||
tools = append(tools, row{"Supported file types", strings.Join(c.SupportedFileTypes, ", ")})
|
||||
}
|
||||
emit("Tools", tools)
|
||||
|
||||
// FAQ
|
||||
faq := []row{}
|
||||
if c.FAQPriorityEnabled {
|
||||
faq = append(faq, row{"FAQ priority", "enabled"})
|
||||
}
|
||||
if c.FAQDirectAnswerThreshold != 0 {
|
||||
faq = append(faq, row{"FAQ direct-answer threshold", fmt.Sprintf("%g", c.FAQDirectAnswerThreshold)})
|
||||
}
|
||||
if c.FAQScoreBoost != 0 {
|
||||
faq = append(faq, row{"FAQ score boost", fmt.Sprintf("%g", c.FAQScoreBoost)})
|
||||
}
|
||||
emit("FAQ", faq)
|
||||
|
||||
// Web search
|
||||
web := []row{}
|
||||
if c.WebSearchEnabled {
|
||||
web = append(web, row{"Web search", "enabled"})
|
||||
}
|
||||
if c.WebSearchMaxResults != 0 {
|
||||
web = append(web, row{"Web search max results", fmt.Sprintf("%d", c.WebSearchMaxResults)})
|
||||
}
|
||||
emit("Web search", web)
|
||||
|
||||
// Multi-turn
|
||||
mt := []row{}
|
||||
if c.MultiTurnEnabled {
|
||||
mt = append(mt, row{"Multi-turn", "enabled"})
|
||||
}
|
||||
if c.HistoryTurns != 0 {
|
||||
mt = append(mt, row{"History turns", fmt.Sprintf("%d", c.HistoryTurns)})
|
||||
}
|
||||
emit("Multi-turn", mt)
|
||||
|
||||
// Fallback
|
||||
fb := []row{}
|
||||
if c.FallbackStrategy != "" {
|
||||
fb = append(fb, row{"Strategy", c.FallbackStrategy})
|
||||
}
|
||||
if c.FallbackResponse != "" {
|
||||
fb = append(fb, row{"Response", c.FallbackResponse})
|
||||
}
|
||||
if c.FallbackPrompt != "" {
|
||||
fb = append(fb, row{"Prompt", text.OneLine(promptPreviewWidth, c.FallbackPrompt)})
|
||||
}
|
||||
emit("Fallback", fb)
|
||||
|
||||
// Templates — system_prompt and context_template can be multi-line;
|
||||
// render headed blocks rather than KV rows for readability.
|
||||
if c.SystemPrompt != "" || c.ContextTemplate != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Templates:")
|
||||
if c.SystemPrompt != "" {
|
||||
fmt.Fprintln(w, " System prompt:")
|
||||
writeIndented(w, c.SystemPrompt, " ")
|
||||
}
|
||||
if c.ContextTemplate != "" {
|
||||
fmt.Fprintln(w, " Context template:")
|
||||
writeIndented(w, c.ContextTemplate, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeIndented prints s with the given prefix on every line. Trailing
|
||||
// newline always added so the next section starts on its own line.
|
||||
func writeIndented(w io.Writer, s, prefix string) {
|
||||
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
|
||||
fmt.Fprintf(w, "%s%s\n", prefix, line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeViewSvc struct {
|
||||
resp *sdk.Agent
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeViewSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestView_Text_RendersMetadataAndConfig(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Agent{
|
||||
ID: "ag_abc",
|
||||
Name: "Research",
|
||||
Description: "deep-dive helper",
|
||||
IsBuiltin: true,
|
||||
CreatedAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 5, 2, 13, 30, 0, 0, time.UTC),
|
||||
Config: &sdk.AgentConfig{
|
||||
AgentMode: "smart-reasoning",
|
||||
ModelID: "model_42",
|
||||
KBSelectionMode: "selected",
|
||||
KnowledgeBases: []string{"kb_x", "kb_y"},
|
||||
AllowedTools: []string{"knowledge_search", "web_search"},
|
||||
WebSearchEnabled: true,
|
||||
},
|
||||
}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"ag_abc", "Research", "deep-dive helper", "Builtin:", "Identity", "LLM", "KB attachment", "Tools", "smart-reasoning", "model_42", "selected", "kb_x", "knowledge_search", "Web search"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentViewFields_TopLevelOnly pins the contract that the field-hint
|
||||
// list on `agent view` only lists top-level Agent keys (including `config`
|
||||
// as a whole). Nested AgentConfig fields are reachable via --jq.
|
||||
func TestAgentViewFields_TopLevelOnly(t *testing.T) {
|
||||
for _, want := range []string{"id", "name", "config", "created_at"} {
|
||||
if !slices.Contains(agentViewFields, want) {
|
||||
t.Errorf("agentViewFields missing top-level key %q", want)
|
||||
}
|
||||
}
|
||||
for _, dotted := range []string{"config.system_prompt", "config.model_id", "config.fallback_strategy"} {
|
||||
if slices.Contains(agentViewFields, dotted) {
|
||||
t.Errorf("agentViewFields must not list dotted nested key %q (the --jq projection path handles nesting directly)", dotted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAgent_RendersAllGroupsWithOmitEmpty validates the grouped
|
||||
// text rendering: present groups print, zero-value fields omit, and an
|
||||
// entire section is suppressed when all of its fields are zero.
|
||||
func TestRenderAgent_RendersAllGroupsWithOmitEmpty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
ag := &sdk.Agent{
|
||||
ID: "ag_abc",
|
||||
Name: "Test",
|
||||
Config: &sdk.AgentConfig{
|
||||
AgentMode: "smart-reasoning",
|
||||
SystemPrompt: "You help users.",
|
||||
ModelID: "model-x",
|
||||
Temperature: 0.7,
|
||||
KBSelectionMode: "selected",
|
||||
KnowledgeBases: []string{"kb_a"},
|
||||
FAQPriorityEnabled: true,
|
||||
WebSearchEnabled: false, // zero — omitted in text
|
||||
FallbackStrategy: "fixed",
|
||||
FallbackResponse: "I don't know.",
|
||||
},
|
||||
}
|
||||
renderAgent(iostreams.IO.Out, ag)
|
||||
body := out.String()
|
||||
// Group labels appear:
|
||||
for _, label := range []string{"Identity", "LLM", "KB attachment", "FAQ", "Fallback", "Templates"} {
|
||||
if !strings.Contains(body, label) {
|
||||
t.Errorf("missing group label %q in:\n%s", label, body)
|
||||
}
|
||||
}
|
||||
// Set fields rendered:
|
||||
for _, want := range []string{"smart-reasoning", "model-x", "You help users."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing value %q in:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Zero-value fields omitted (web search disabled → max_results not shown):
|
||||
if strings.Contains(body, "web_search_max_results") {
|
||||
t.Errorf("zero-valued web_search_max_results leaked into:\n%s", body)
|
||||
}
|
||||
// Section with all zero values must be suppressed entirely (no Retrieval
|
||||
// fields were set in this fixture).
|
||||
if strings.Contains(body, "Retrieval") {
|
||||
t.Errorf("Retrieval section rendered with all-zero fields in:\n%s", body)
|
||||
}
|
||||
// Multi-turn section is all-zero too — must be suppressed.
|
||||
if strings.Contains(body, "Multi-turn") {
|
||||
t.Errorf("Multi-turn section rendered with all-zero fields in:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_Text_OmitsEmptyFields(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Agent{
|
||||
ID: "ag_min",
|
||||
Name: "Minimal",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_min"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if strings.Contains(got, "Description:") {
|
||||
t.Errorf("empty description should be omitted, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Builtin:") {
|
||||
t.Errorf("non-builtin should not render Builtin: line, got:\n%s", got)
|
||||
}
|
||||
// With Config==nil none of the grouped config sections should appear.
|
||||
for _, label := range []string{"LLM:", "KB attachment:", "Retrieval:", "Query rewrite:", "Tools:", "FAQ:", "Web search:", "Multi-turn:", "Fallback:", "Templates:"} {
|
||||
if strings.Contains(got, label) {
|
||||
t.Errorf("nil Config should not render %q, got:\n%s", label, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Agent{ID: "ag_json", Name: "JSONy"}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "ag_json"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Agent `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if got.ID != "ag_json" || got.Name != "JSONy" {
|
||||
t.Errorf("envelope.data shape wrong: id=%s name=%s", got.ID, got.Name)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("envelope.ok must be true, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_404_MapsToResourceNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{err: errors.New("HTTP error 404: agent not found")}
|
||||
err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeResourceNotFound {
|
||||
t.Errorf("expected resource.not_found, got %s", typed.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user