feat(onyx-cli): agentic search command (#11001)

This commit is contained in:
Wenxi authored and GitHub committed 2026-05-12 01:44:14 +00:00
1 parent 95a838c568
commit d44015d4e4
11 files changed
+735 -24

No files matched your search

@@ -31,6 +31,14 @@ Test Suite:
15. test_help_non_tty - No subcommand prints help, exits 0
16. test_version_flag - Prints client and server version
17. test_experiments - Lists feature flags
18. test_search_returns_results - Search returns seeded document content
19. test_search_raw - --raw outputs full SearchAPIResponse as JSON
20. test_search_truncation - --max-output truncates with temp file path
21. test_search_no_query - No query returns exit code 2
22. test_search_bad_pat - Invalid PAT returns exit code 4
23. test_search_not_configured - Missing PAT returns exit code 3
24. test_search_source_filter - --source filters results to matching source types
25. test_search_agent_id - --agent-id scopes search to a persona's document sets
"""
import json
@@ -41,9 +49,14 @@ from pathlib import Path
import pytest
from onyx.configs.constants import DocumentSource
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.document import DocumentManager
from tests.integration.common_utils.managers.document_set import DocumentSetManager
from tests.integration.common_utils.managers.pat import PATManager
from tests.integration.common_utils.managers.persona import PersonaManager
from tests.integration.common_utils.test_models import DATestAPIKey
from tests.integration.common_utils.test_models import DATestLLMProvider
from tests.integration.common_utils.test_models import DATestPersona
from tests.integration.common_utils.test_models import DATestUser
@@ -373,3 +386,187 @@ def test_experiments(cli_binary: Path) -> None:
assert result.returncode == 0
assert "Stream Markdown" in result.stdout
# --- search ---
def test_search_returns_results(
cli_binary: Path,
pat_token: str,
admin_user: DATestUser,
llm_provider: DATestLLMProvider, # noqa: ARG001
api_key: DATestAPIKey,
) -> None:
"""Search returns results containing the seeded document content."""
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user)
phrase = "cli-search-unique-phrase-alpha"
DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key)
result = run_cli(cli_binary, ["search", phrase], pat=pat_token, timeout=120)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert len(result.stdout.strip()) > 0
assert phrase in result.stdout
def test_search_raw(
cli_binary: Path,
pat_token: str,
admin_user: DATestUser,
llm_provider: DATestLLMProvider, # noqa: ARG001
api_key: DATestAPIKey,
) -> None:
"""--raw outputs the full SearchAPIResponse as JSON."""
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user)
phrase = "cli-search-raw-unique-phrase"
doc = DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key)
result = run_cli(
cli_binary, ["search", "--raw", phrase], pat=pat_token, timeout=120
)
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert isinstance(data["results"], list)
assert len(data["results"]) > 0
assert isinstance(data["llm_facing_text"], str)
assert len(data["llm_facing_text"]) > 0
assert isinstance(data["citation_mapping"], dict)
result_doc_ids = {r["document_id"] for r in data["results"]}
assert doc.id in result_doc_ids
def test_search_truncation(
cli_binary: Path,
pat_token: str,
admin_user: DATestUser,
llm_provider: DATestLLMProvider, # noqa: ARG001
api_key: DATestAPIKey,
) -> None:
"""--max-output truncates output and shows temp file path."""
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user)
phrase = "cli-search-truncation-unique"
DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key)
result = run_cli(
cli_binary,
["search", "--max-output", "50", phrase],
pat=pat_token,
timeout=120,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert "response truncated" in result.stdout
assert "Full response:" in result.stdout
def test_search_no_query(
cli_binary: Path,
pat_token: str,
admin_user: DATestUser, # noqa: ARG001
) -> None:
"""Search with no query returns BadRequest (exit code 2)."""
result = run_cli(cli_binary, ["search"], pat=pat_token)
assert result.returncode == 2
def test_search_bad_pat(
cli_binary: Path,
admin_user: DATestUser, # noqa: ARG001
) -> None:
"""Search with an invalid PAT returns AuthFailure (exit code 4)."""
result = run_cli(cli_binary, ["search", "test"], pat="bad-token")
assert result.returncode == 4
def test_search_not_configured(
cli_binary: Path,
admin_user: DATestUser, # noqa: ARG001
) -> None:
"""Search without PAT returns NotConfigured (exit code 3)."""
result = run_cli(cli_binary, ["search", "test"])
assert result.returncode == 3
def test_search_source_filter(
cli_binary: Path,
pat_token: str,
admin_user: DATestUser,
llm_provider: DATestLLMProvider, # noqa: ARG001
api_key: DATestAPIKey,
) -> None:
"""--source filters results to matching source types."""
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user)
phrase = "cli-search-source-filter-unique"
DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key)
# TODO(@wenxi-onyx): Make the integration test manager allow source types during seeding
result = run_cli(
cli_binary,
["search", "--raw", "--source", DocumentSource.NOT_APPLICABLE.value, phrase],
pat=pat_token,
timeout=120,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert len(data["results"]) > 0
# All results should match the requested source
for r in data["results"]:
assert r["source_type"] == DocumentSource.NOT_APPLICABLE.value
def test_search_agent_id(
cli_binary: Path,
pat_token: str,
admin_user: DATestUser,
llm_provider: DATestLLMProvider, # noqa: ARG001
api_key: DATestAPIKey,
) -> None:
"""--agent-id scopes search to a persona's document sets."""
cc_pair_in = CCPairManager.create_from_scratch(user_performing_action=admin_user)
cc_pair_out = CCPairManager.create_from_scratch(user_performing_action=admin_user)
shared_phrase = "cli-search-agent-scope-unique"
doc_in = DocumentManager.seed_doc_with_content(
cc_pair_in,
f"{shared_phrase} in scope",
api_key,
)
doc_out = DocumentManager.seed_doc_with_content(
cc_pair_out,
f"{shared_phrase} out of scope",
api_key,
)
doc_set = DocumentSetManager.create(
cc_pair_ids=[cc_pair_in.id],
user_performing_action=admin_user,
)
DocumentSetManager.wait_for_sync(
user_performing_action=admin_user,
document_sets_to_check=[doc_set],
)
persona = PersonaManager.create(
user_performing_action=admin_user,
document_set_ids=[doc_set.id],
is_public=True,
)
result = run_cli(
cli_binary,
["search", "--raw", "--agent-id", str(persona.id), shared_phrase],
pat=pat_token,
timeout=120,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
result_doc_ids = {r["document_id"] for r in data["results"]}
assert doc_in.id in result_doc_ids
assert doc_out.id not in result_doc_ids
+1
View File
@@ -96,6 +96,7 @@ func Execute() error {
chatCmd := newChatCmd()
rootCmd.AddCommand(chatCmd)
rootCmd.AddCommand(newAskCmd(ios))
rootCmd.AddCommand(newSearchCmd(ios))
rootCmd.AddCommand(newAgentsCmd(ios))
rootCmd.AddCommand(newConfigureCmd(ios))
rootCmd.AddCommand(newValidateConfigCmd(ios))
+160
View File
@@ -0,0 +1,160 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/iostreams"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/overflow"
"github.com/spf13/cobra"
)
// buildSearchRequest maps CLI flags into a SearchRequest.
// Boolean "set" parameters indicate whether the corresponding flag was explicitly provided.
func buildSearchRequest(
query string,
sources []string,
days int, daysSet bool,
limit int, limitSet bool,
agentID int, agentIDSet bool,
defaultAgentID int,
noQueryExpansion bool,
) models.SearchRequest {
req := models.SearchRequest{Query: query}
for _, s := range sources {
s = strings.TrimSpace(s)
if s != "" {
req.Sources = append(req.Sources, s)
}
}
if daysSet {
req.TimeCutoffDays = &days
}
if limitSet {
req.NumResults = limit
}
if agentIDSet {
req.PersonaID = &agentID
} else if defaultAgentID != 0 {
req.PersonaID = &defaultAgentID
}
if noQueryExpansion {
req.SkipQueryExpansion = true
}
return req
}
func newSearchCmd(ios *iostreams.IOStreams) *cobra.Command {
var (
searchSources string
searchDays int
searchLimit int
searchAgentID int
searchRaw bool
searchNoQueryExpansion bool
maxOutput int
)
cmd := &cobra.Command{
Use: "search [query]",
Short: "Search company knowledge and return ranked documents",
Long: `Search the Onyx knowledge base and return ranked, cited documents.
Results are retrieved using the full search pipeline: LLM query expansion,
hybrid retrieval, document selection, and context expansion — the same
search quality as the Onyx chat interface.
By default, output is the LLM-facing JSON that SearchTool produces — a
{"results": [...]} object with citation IDs, titles, content, and source
types. Use --raw for the full API response including document IDs, scores,
links, and citation mapping.
When stdout is not a TTY, output is truncated to --max-output bytes and the
full response is saved to a temp file.`,
Args: cobra.MaximumNArgs(1),
Example: ` onyx-cli search "What is our deployment process?"
onyx-cli search --source slack "auth migration status"
onyx-cli search --days 30 --limit 5 "recent production incidents"
onyx-cli search --agent-id 5 "engineering roadmap"
onyx-cli search --raw "API documentation" | jq '.results[].title'
onyx-cli search --no-query-expansion "exact error message text"`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, client, err := requireClient()
if err != nil {
return err
}
if len(args) == 0 {
return exitcodes.New(exitcodes.BadRequest,
"no query provided\n Usage: onyx-cli search \"your query\"")
}
var sources []string
if cmd.Flags().Changed("source") {
sources = strings.Split(searchSources, ",")
}
req := buildSearchRequest(
args[0],
sources,
searchDays, cmd.Flags().Changed("days"),
searchLimit, cmd.Flags().Changed("limit"),
searchAgentID, cmd.Flags().Changed("agent-id"),
cfg.DefaultAgentID,
searchNoQueryExpansion,
)
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer stop()
isTTY := ios.IsStdoutTTY
if isTTY {
fmt.Fprintf(ios.ErrOut, "\033[2mSearching...\033[0m\n")
}
resp, err := client.Search(ctx, req)
if err != nil {
return apiErrorToExit(err, "search failed")
}
if searchRaw {
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
fmt.Fprintln(ios.Out, string(data))
return nil
}
truncateAt := 0
if cmd.Flags().Changed("max-output") {
truncateAt = maxOutput
} else if !isTTY {
truncateAt = defaultMaxOutputBytes
}
ow := &overflow.Writer{Limit: truncateAt, Out: ios.Out, ErrOut: ios.ErrOut}
ow.Write(resp.LLMFacingText)
ow.Finish()
return nil
},
}
cmd.Flags().StringVar(&searchSources, "source", "", "Filter by source type (comma-separated: slack,google_drive)")
cmd.Flags().IntVar(&searchDays, "days", 0, "Only return results from the last N days")
cmd.Flags().IntVar(&searchLimit, "limit", 0, "Maximum number of results (default: server decides)")
cmd.Flags().IntVar(&searchAgentID, "agent-id", 0, "Agent ID for scoped search")
cmd.Flags().BoolVar(&searchRaw, "raw", false, "Output full API response (results with scores, links, document IDs, citation mapping)")
cmd.Flags().BoolVar(&searchNoQueryExpansion, "no-query-expansion", false, "Skip LLM query expansion (faster, less comprehensive)")
cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes,
"Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY, ignored with --raw)")
return cmd
}
+214
View File
@@ -0,0 +1,214 @@
package cmd
import (
"bytes"
"errors"
"testing"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/iostreams"
"github.com/spf13/cobra"
)
func TestSearch_NoQuery(t *testing.T) {
ios := &iostreams.IOStreams{
In: &bytes.Buffer{},
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
IsStdinTTY: true,
IsStdoutTTY: true,
}
cmd := newSearchCmd(ios)
cmd.SetArgs([]string{})
// Stub RunE so we don't need a real client, but keep the arg check.
origRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return exitcodes.New(exitcodes.BadRequest,
"no query provided\n Usage: onyx-cli search \"your query\"")
}
return origRunE(cmd, args)
}
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for missing query")
}
var exitErr *exitcodes.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("want *ExitError, got %T: %v", err, err)
}
if exitErr.Code != exitcodes.BadRequest {
t.Errorf("exit code = %d, want %d", exitErr.Code, exitcodes.BadRequest)
}
}
func TestBuildSearchRequest(t *testing.T) {
intPtr := func(v int) *int { return &v }
tests := []struct {
name string
query string
sources []string
days int
daysSet bool
limit int
limitSet bool
agentID int
agentIDSet bool
defaultAgentID int
noQueryExpansion bool
wantSources []string
wantTimeCutoffDays *int
wantNumResults int
wantPersonaID *int
wantSkipQueryExpansion bool
}{
{
name: "no_sources",
query: "test query",
wantSources: nil,
},
{
name: "two_sources",
query: "test query",
sources: []string{"slack", "google_drive"},
wantSources: []string{"slack", "google_drive"},
},
{
name: "empty_strings_filtered_from_sources",
query: "test query",
sources: []string{"slack", "", " ", "google_drive"},
wantSources: []string{"slack", "google_drive"},
},
{
name: "days_limit_agentID_set",
query: "test query",
days: 30,
daysSet: true,
limit: 5,
limitSet: true,
agentID: 3,
agentIDSet: true,
wantTimeCutoffDays: intPtr(30),
wantNumResults: 5,
wantPersonaID: intPtr(3),
},
{
name: "unset_flags_produce_zero_values",
query: "test query",
wantSources: nil,
wantTimeCutoffDays: nil,
wantNumResults: 0,
wantPersonaID: nil,
},
{
name: "agent_id_zero_explicitly_set",
query: "test query",
agentID: 0,
agentIDSet: true,
wantPersonaID: intPtr(0),
},
{
name: "no_query_expansion",
query: "exact error text",
noQueryExpansion: true,
wantSkipQueryExpansion: true,
},
{
name: "default_agent_id_fallback",
query: "test query",
defaultAgentID: 7,
wantPersonaID: intPtr(7),
},
{
name: "explicit_agent_id_overrides_default",
query: "test query",
agentID: 2,
agentIDSet: true,
defaultAgentID: 7,
wantPersonaID: intPtr(2),
},
{
name: "default_agent_id_zero_not_sent",
query: "test query",
defaultAgentID: 0,
wantPersonaID: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := buildSearchRequest(
tt.query,
tt.sources,
tt.days, tt.daysSet,
tt.limit, tt.limitSet,
tt.agentID, tt.agentIDSet,
tt.defaultAgentID,
tt.noQueryExpansion,
)
if req.Query != tt.query {
t.Errorf("Query = %q, want %q", req.Query, tt.query)
}
// Sources
if tt.wantSources == nil {
if req.Sources != nil {
t.Errorf("Sources = %v, want nil", req.Sources)
}
} else {
if len(req.Sources) != len(tt.wantSources) {
t.Fatalf("Sources length = %d, want %d: %v", len(req.Sources), len(tt.wantSources), req.Sources)
}
for i, s := range tt.wantSources {
if req.Sources[i] != s {
t.Errorf("Sources[%d] = %q, want %q", i, req.Sources[i], s)
}
}
}
// TimeCutoffDays
if tt.wantTimeCutoffDays == nil {
if req.TimeCutoffDays != nil {
t.Errorf("TimeCutoffDays = %v, want nil", *req.TimeCutoffDays)
}
} else {
if req.TimeCutoffDays == nil {
t.Fatalf("TimeCutoffDays = nil, want %d", *tt.wantTimeCutoffDays)
}
if *req.TimeCutoffDays != *tt.wantTimeCutoffDays {
t.Errorf("TimeCutoffDays = %d, want %d", *req.TimeCutoffDays, *tt.wantTimeCutoffDays)
}
}
// NumResults
if req.NumResults != tt.wantNumResults {
t.Errorf("NumResults = %d, want %d", req.NumResults, tt.wantNumResults)
}
// PersonaID
if tt.wantPersonaID == nil {
if req.PersonaID != nil {
t.Errorf("PersonaID = %d, want nil", *req.PersonaID)
}
} else {
if req.PersonaID == nil {
t.Fatalf("PersonaID = nil, want %d", *tt.wantPersonaID)
}
if *req.PersonaID != *tt.wantPersonaID {
t.Errorf("PersonaID = %d, want %d", *req.PersonaID, *tt.wantPersonaID)
}
}
// SkipQueryExpansion
if req.SkipQueryExpansion != tt.wantSkipQueryExpansion {
t.Errorf("SkipQueryExpansion = %v, want %v", req.SkipQueryExpansion, tt.wantSkipQueryExpansion)
}
})
}
}
+20 -2
View File
@@ -79,7 +79,7 @@ func wrapTimeoutError(err error) error {
return err
}
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error {
func (c *Client) doJSONWith(ctx context.Context, httpClient *http.Client, method, path string, reqBody any, result any) error {
var body io.Reader
if reqBody != nil {
data, err := json.Marshal(reqBody)
@@ -97,7 +97,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return wrapTimeoutError(err)
}
@@ -113,6 +113,23 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r
return nil
}
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error {
return c.doJSONWith(ctx, c.httpClient, method, path, reqBody, result)
}
func (c *Client) doJSONLong(ctx context.Context, method, path string, reqBody any, result any) error {
return c.doJSONWith(ctx, c.longHTTPClient, method, path, reqBody, result)
}
// Search calls POST /api/search and returns the response.
func (c *Client) Search(ctx context.Context, req models.SearchRequest) (*models.SearchResponse, error) {
var resp models.SearchResponse
if err := c.doJSONLong(ctx, "POST", "/search", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// TestConnection checks if the server is reachable and credentials are valid.
// Returns nil on success, or an error with a descriptive message on failure.
func (c *Client) TestConnection(ctx context.Context) error {
@@ -315,6 +332,7 @@ type ClientAPI interface {
GetBackendVersion(ctx context.Context) (string, error)
StopChatSession(ctx context.Context, sessionID string)
SendMessageStream(ctx context.Context, message string, chatSessionID *string, agentID int, parentMessageID *int, fileDescriptors []models.FileDescriptorPayload) <-chan models.StreamEvent
Search(ctx context.Context, req models.SearchRequest) (*models.SearchResponse, error)
}
var _ ClientAPI = (*Client)(nil)
+52
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/testutil"
)
@@ -23,6 +24,57 @@ func TestListAgents_Timeout(t *testing.T) {
}
}
func TestSearch_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("method = %s, want POST", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/search") {
t.Errorf("path = %s, want /api/search", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"results": [{"citation_id": 1, "document_id": "doc-1", "chunk_ind": 0, "title": "Test", "blurb": "blurb", "link": null, "source_type": "web", "score": 0.95, "updated_at": null}],
"llm_facing_text": "{\"results\": []}",
"citation_mapping": {"1": "doc-1"}
}`))
}))
defer srv.Close()
client := testutil.NewClient(srv.URL + "/api")
resp, err := client.Search(t.Context(), models.SearchRequest{Query: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
if resp.Results[0].DocumentID != "doc-1" {
t.Errorf("document_id = %q, want %q", resp.Results[0].DocumentID, "doc-1")
}
if resp.LLMFacingText == "" {
t.Error("llm_facing_text is empty")
}
}
func TestSearch_401(t *testing.T) {
srv := testutil.StatusServer(401)
defer srv.Close()
client := testutil.NewClient(srv.URL)
_, err := client.Search(t.Context(), models.SearchRequest{Query: "test"})
if err == nil {
t.Fatal("expected error for 401")
}
var apiErr *api.OnyxAPIError
if !errors.As(err, &apiErr) {
t.Fatalf("want *OnyxAPIError, got %T: %v", err, err)
}
if apiErr.StatusCode != 401 {
t.Errorf("status = %d, want 401", apiErr.StatusCode)
}
}
// TestTestConnection_AWSELB403 verifies that TestConnection detects an AWS
// ALB/ELB 403 by inspecting the Server response header. This header-sniffing
// logic cannot be exercised by integration tests since it requires a specific
+57 -20
View File
@@ -53,13 +53,48 @@ Exit code 0 on success. Non-zero with a descriptive error on failure (see exit c
## Commands
### Search documents
```bash
onyx-cli search "What is our deployment process?"
```
Returns ranked, cited documents from the Onyx knowledge base as JSON. The default output is the LLM-facing format — `{"results": [...]}` with citation IDs, titles, content, and source types. Use `--raw` for the full API response including document IDs, scores, links, and citation mapping.
```bash
# Filter by source
onyx-cli search --source slack,google_drive "auth migration status"
# Recent results only
onyx-cli search --days 30 --limit 5 "recent production incidents"
# Use a specific agent for scoped search
onyx-cli search --agent-id 5 "engineering roadmap"
# Full API response for programmatic use
onyx-cli search --raw "API documentation" | jq '.results[].title'
# Skip query expansion for exact matching
onyx-cli search --no-query-expansion "exact error message text"
```
| Flag | Type | Description |
| ----------------------- | ------ | ---------------------------------------------------------------- |
| `--source` | string | Filter by source type (comma-separated: slack,google_drive) |
| `--days` | int | Only return results from the last N days |
| `--limit` | int | Maximum number of results (default: server decides) |
| `--agent-id` | int | Agent ID for scoped search (inherits filters, document sets) |
| `--raw` | bool | Output full API response (results with scores, links, document IDs, citation mapping) |
| `--no-query-expansion` | bool | Skip LLM query expansion (faster, less comprehensive) |
| `--max-output` | int | Max bytes to print before truncating (0 to disable, default 4096 for non-TTY, ignored with --raw) |
### Ask a question
```bash
onyx-cli ask "What is our company's PTO policy?"
```
Streams the answer as plain text to stdout. When stdout is not a TTY, output is truncated to 4096 bytes and the full response is saved to a temp file (path printed at the end). Use `--max-output 0` to disable truncation.
Streams an LLM-generated answer as plain text to stdout. Use `search` instead when you need the source documents rather than a synthesized answer. When stdout is not a TTY, output is truncated to 4096 bytes and the full response is saved to a temp file (path printed at the end). Use `--max-output 0` to disable truncation.
```bash
# Use a specific agent
@@ -87,7 +122,7 @@ onyx-cli agents
onyx-cli agents --json
```
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured JSON output. Use agent IDs with `ask --agent-id`.
Prints a table of agent IDs, names, and descriptions. Use `--json` for structured JSON output. Use agent IDs with `search --agent-id` or `ask --agent-id`.
### Validate configuration
@@ -95,14 +130,14 @@ Prints a table of agent IDs, names, and descriptions. Use `--json` for structure
onyx-cli validate-config
```
Checks config exists, PAT is present, server is reachable, and credentials are valid. Use before `ask` or `agents` to confirm the CLI is properly set up.
Checks config exists, PAT is present, server is reachable, and credentials are valid. Use before `search`, `ask`, or `agents` to confirm the CLI is properly set up.
## Output Conventions
- **stdout**: Results only (answer text, agent list, status)
- **stderr**: Progress indicators, warnings, errors
- **Non-TTY**: No ANSI escape codes, no interactive prompts
- **Truncation**: When stdout is not a TTY, `ask` output is truncated to 4096 bytes. Full response is saved to a temp file whose path is printed. Read the temp file for more.
- **Truncation**: When stdout is not a TTY, `search` and `ask` output is truncated to 4096 bytes. Full response is saved to a temp file whose path is printed. Read the temp file for more.
## Exit Codes
@@ -121,17 +156,22 @@ Checks config exists, PAT is present, server is reachable, and credentials are v
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no way to chain context across multiple invocations — every call starts fresh.
Each invocation is independent. `search` does not create a chat session. `ask` creates a one-shot chat session. There is no way to chain context across multiple invocations — every call starts fresh.
## When to Use
Use `onyx-cli ask` when:
- The user asks about company-specific information (policies, docs, processes)
- You need to search internal knowledge bases or connected data sources
- The user references Onyx or wants to query their documents
- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources
Use `onyx-cli search` when:
- You need to find specific documents or gather context for a task
- You want to reason over multiple source documents yourself
- The user asks you to look up or find information in company knowledge
- You need cited, structured results (document IDs, source types, content)
Do NOT use when:
Use `onyx-cli ask` when:
- The user wants a direct answer, summarization, or synthesis
- A human-readable response is more useful than raw documents
- You need the LLM to reason across sources and produce an answer
Do NOT use either when:
- The question is about general programming knowledge (use your own knowledge)
- The user is asking about code in the current repository (use grep/read tools)
- The user hasn't mentioned Onyx and the question doesn't require internal company data
@@ -139,16 +179,13 @@ Do NOT use when:
## Examples
```bash
# Simple question
# Search for documents
onyx-cli search "What is our deployment process?"
onyx-cli search --source slack "auth migration status"
onyx-cli search --raw "API documentation" | jq '.results[].title'
# Ask for an answer
onyx-cli ask "What are the steps to deploy to production?"
# Use a specialized agent
onyx-cli ask --agent-id 3 "What were the action items from last week's standup?"
# Pipe context with a question
cat error.log | onyx-cli ask --prompt "What does this error mean?"
# Read the full response when truncated
onyx-cli ask "Describe the full onboarding process" 2>/dev/null
# If truncated, read the temp file path from the last line of output
```
+1 -1
View File
@@ -24,7 +24,7 @@ func ForHTTPStatus(statusCode int) Code {
switch {
case statusCode >= 200 && statusCode < 300:
return Success
case statusCode == 400:
case statusCode == 400 || statusCode == 422:
return BadRequest
case statusCode == 401 || statusCode == 403:
return AuthFailure
+1
View File
@@ -64,6 +64,7 @@ func TestForHTTPStatus(t *testing.T) {
}{
{200, Success},
{400, BadRequest},
{422, BadRequest},
{401, AuthFailure},
{403, AuthFailure},
{404, NotAvailable},
+31
View File
@@ -100,3 +100,34 @@ type Placement struct {
TabIndex int `json:"tab_index"`
SubTurnIndex *int `json:"sub_turn_index"`
}
// SearchRequest is the request body for POST /api/search.
type SearchRequest struct {
Query string `json:"query"`
Sources []string `json:"sources,omitempty"`
DocumentSets []string `json:"document_sets,omitempty"`
TimeCutoffDays *int `json:"time_cutoff_days,omitempty"`
NumResults int `json:"num_results,omitempty"`
PersonaID *int `json:"persona_id,omitempty"`
SkipQueryExpansion bool `json:"skip_query_expansion,omitempty"`
}
// SearchResult is a single document result from the search API.
type SearchResult struct {
CitationID *int `json:"citation_id"`
DocumentID string `json:"document_id"`
ChunkInd int `json:"chunk_ind"`
Title string `json:"title"`
Blurb string `json:"blurb"`
Link *string `json:"link"`
SourceType string `json:"source_type"`
Score *float64 `json:"score"`
UpdatedAt *string `json:"updated_at"`
}
// SearchResponse is the response from POST /api/search.
type SearchResponse struct {
Results []SearchResult `json:"results"`
LLMFacingText string `json:"llm_facing_text"`
CitationMapping map[int]string `json:"citation_mapping"`
}
@@ -94,7 +94,7 @@ type SearchRequest struct {
// SearchResult is a single document result from the search API.
type SearchResult struct {
CitationID int `json:"citation_id"`
CitationID *int `json:"citation_id"`
DocumentID string `json:"document_id"`
ChunkInd int `json:"chunk_ind"`
Title string `json:"title"`