Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Release Notes.

### Features

* Add the `ai-agent` commands, `list`, `files` and `view`, for the AI agent conversations the AI Sessionizer lands in the OAP (11.1.0+); `view` reads the whole conversation as one `asz.view` document from the OAP's streamed route on the GraphQL host by @wu-sheng in https://github.com/apache/skywalking-cli/pull/234
* Add the sub-command `profiling async` for async-profiler query API by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203
* Support the owner in MQE response by using [10.2 MQE query protocol](https://github.com/apache/skywalking-query-protocol/pull/141) by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203
* Add the sub-command `alarm autocomplete-keys` and `alarm auto-complete-values` for alarm query API by @mrproliu in https://github.com/apache/skywalking-cli/pull/210
Expand Down
34 changes: 34 additions & 0 deletions assets/graphqls/aiagent/ConversationRawFiles.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Licensed to Apache Software Foundation (ASF) under one or more contributor
# license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright
# ownership. Apache Software Foundation (ASF) licenses this file to you under
# the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

# The body is read from storage only when selected; $body is true on the export path.
query ($condition: ConversationCondition!, $files: [ID!], $body: Boolean!) {
result: getConversationRawFiles(condition: $condition, files: $files) {
errorReason
files {
id
format
session
seq
round
digest
bytes
timestamp
body @include(if: $body)
}
}
}
36 changes: 36 additions & 0 deletions assets/graphqls/aiagent/ListConversations.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Licensed to Apache Software Foundation (ASF) under one or more contributor
# license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright
# ownership. Apache Software Foundation (ASF) licenses this file to you under
# the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

query ($condition: ConversationListCondition!, $duration: Duration!) {
result: listConversations(condition: $condition, duration: $duration) {
errorReason
conversations {
conversation
serviceInstanceId
serviceInstanceName
title
round
talks
steps
streams
segments
unresolved
from
to
}
}
}
2 changes: 2 additions & 0 deletions cmd/swctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"runtime"

"github.com/apache/skywalking-cli/internal/commands/admin"
"github.com/apache/skywalking-cli/internal/commands/aiagent"
"github.com/apache/skywalking-cli/internal/commands/alarm"
"github.com/apache/skywalking-cli/internal/commands/browser"
"github.com/apache/skywalking-cli/internal/commands/completion"
Expand Down Expand Up @@ -117,6 +118,7 @@ services, service instances, etc.`
menu.Command,
hierarchy.Command,
admin.Command,
aiagent.Command,
}

app.Before = interceptor.BeforeChain(
Expand Down
2 changes: 1 addition & 1 deletion dist/LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ The text of each license is also included at licenses/license-[project].txt.
sigs.k8s.io/controller-runtime v0.20.4 Apache-2.0
sigs.k8s.io/randfill v1.0.0 Apache-2.0
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 Apache-2.0
skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 Apache-2.0
skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 Apache-2.0

========================================================================
Apache-2.0 and BSD-3-Clause licenses
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ require (
gopkg.in/yaml.v2 v2.4.0
k8s.io/apimachinery v0.33.1
sigs.k8s.io/controller-runtime v0.20.4
skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031
skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -543,5 +543,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxg
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 h1:iMd6gzltWrWOtV3COm0mWydeXhpy7r1vkNlTsm7Co0g=
skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031/go.mod h1:Vj9vINJYsTQASPsbQ1i81YgH8nFC/Xds4GjcXvmRYwM=
skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 h1:XZ27v0cI1QaSyQK48fJfcvpGyhKUv22ULp2cS3l7snI=
skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302/go.mod h1:tsTLCXFg0zZ1lqr5+7tDMsZvZA+YFUCQe0lRctv23yc=
40 changes: 40 additions & 0 deletions internal/commands/aiagent/aiagent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to Apache Software Foundation (ASF) under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Apache Software Foundation (ASF) licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

// Package aiagent holds the commands for the conversations of long-lived AI agents
// that the AI Sessionizer (apache/skywalking-ai-sessionizer) lands in the OAP under
// the AI_AGENT layer: the list page, the raw-file export, and the conversation itself
// as one asz.view document.
package aiagent

import (
"github.com/urfave/cli/v2"
)

var Command = &cli.Command{
Name: "ai-agent",
Usage: "AI agent conversations landed by the AI Sessionizer",
UsageText: `The AI Sessionizer collects an agent runtime's transcripts and pushes them to the OAP
under the AI_AGENT layer. "list" and "files" are GraphQL queries on the "--base-url"
endpoint; "view" reads the whole conversation as one asz.view document from the OAP's
streamed route on the same host, GET /ai-agent/conversations/{conversation}/v1/view.`,
Subcommands: []*cli.Command{
listCommand,
filesCommand,
viewCommand,
},
}
141 changes: 141 additions & 0 deletions internal/commands/aiagent/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Licensed to Apache Software Foundation (ASF) under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Apache Software Foundation (ASF) licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package aiagent

import (
"fmt"
"os"
"path/filepath"
"strings"

api "skywalking.apache.org/repo/goapi/query"

"github.com/urfave/cli/v2"

"github.com/apache/skywalking-cli/internal/commands/interceptor"
"github.com/apache/skywalking-cli/internal/flags"
"github.com/apache/skywalking-cli/pkg/display"
"github.com/apache/skywalking-cli/pkg/display/displayable"
"github.com/apache/skywalking-cli/pkg/graphql/aiagent"
)

var filesCommand = &cli.Command{
Name: "files",
Usage: "List or export the raw files of a conversation, as the OAP stores them",
UsageText: `List every landed file and round of a conversation with its digest and size, or
export them: "--export DIR" reads each body and writes it to its id path under DIR,
which gives a storage root that "asz verify" and "asz view" read like the original.

Examples:
1. The files of a conversation:
$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2

2. Export them all:
$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --export ./root

3. Export two named files:
$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 \
--files 7a3c882e-0dc0-46a0-b814-6613d24b7ac2/streams/main/transcript-20260904T152815.774957000Z-000408.sd \
--export ./root`,
Flags: flags.Flags(
flags.ServiceFlags,
flags.InstanceFlags,
[]cli.Flag{
&cli.StringFlag{
Name: "conversation",
Usage: "`id` of the conversation",
Required: true,
},
&cli.StringFlag{
Name: "files",
Usage: "only these file `ids`, comma separated; without it, every file of the conversation",
},
&cli.StringFlag{
Name: "export",
Usage: "write each file's body to its id path under this `directory`",
},
},
),
Before: interceptor.BeforeChain(
interceptor.ParseService(true),
interceptor.ParseInstance(false),
),
Action: func(ctx *cli.Context) error {
condition := &api.ConversationCondition{
Service: &api.ServiceCondition{ServiceName: ctx.String("service-name")},
Conversation: ctx.String("conversation"),
Instance: instanceCondition(ctx),
}
var files []string
if arg := strings.TrimSpace(ctx.String("files")); arg != "" {
files = strings.Split(arg, ",")
}
exportDir := ctx.String("export")

raw, err := aiagent.RawFiles(ctx.Context, condition, files, exportDir != "")
if err != nil {
return err
}
if raw.ErrorReason != nil && *raw.ErrorReason != "" {
return fmt.Errorf("%s", *raw.ErrorReason)
}
if exportDir == "" {
return display.Display(ctx.Context, &displayable.Displayable{Data: raw, Condition: condition})
}

written, err := export(exportDir, raw.Files)
if err != nil {
return err
}
return display.Display(ctx.Context, &displayable.Displayable{Data: written, Condition: condition})
},
}

// Exported is one file written by "--export": its id path and size, the body left out.
type Exported struct {
ID string `json:"id"`
Path string `json:"path"`
Bytes int `json:"bytes"`
}

// export writes each body to its id path under dir. An id is a relative path inside the
// Sessionizer's storage root; one that would leave dir is refused.
func export(dir string, files []*api.ConversationRawFile) ([]Exported, error) {
root, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
out := make([]Exported, 0, len(files))
for _, f := range files {
if f.Body == nil {
return nil, fmt.Errorf("the OAP returned no body for %s", f.ID)
}
path := filepath.Join(root, filepath.FromSlash(f.ID))
if !strings.HasPrefix(path, root+string(filepath.Separator)) {
return nil, fmt.Errorf("refusing to write %s outside %s", f.ID, root)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(*f.Body), 0o644); err != nil { // #nosec G306 -- a landed file is readable by design
return nil, err
}
out = append(out, Exported{ID: f.ID, Path: path, Bytes: len(*f.Body)})
}
return out, nil
}
93 changes: 93 additions & 0 deletions internal/commands/aiagent/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Licensed to Apache Software Foundation (ASF) under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Apache Software Foundation (ASF) licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package aiagent

import (
api "skywalking.apache.org/repo/goapi/query"

"github.com/urfave/cli/v2"

"github.com/apache/skywalking-cli/internal/commands/interceptor"
"github.com/apache/skywalking-cli/internal/flags"
"github.com/apache/skywalking-cli/internal/model"
"github.com/apache/skywalking-cli/pkg/display"
"github.com/apache/skywalking-cli/pkg/display/displayable"
"github.com/apache/skywalking-cli/pkg/graphql/aiagent"
)

var listCommand = &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List the conversations of an AI agent service",
UsageText: `List the conversations of an AI agent service active in the duration, newest first,
one row per conversation from its newest round.

Examples:
1. The conversations of service "Claude Code" in the last 30 minutes:
$ swctl ai-agent list --service-name "Claude Code"

2. Only those pushed by one Sessionizer, in a day:
$ swctl ai-agent list --service-name "Claude Code" --instance-name laptop --start 2026-09-01 --end 2026-09-02`,
Flags: flags.Flags(
flags.DurationFlags,
flags.ServiceFlags,
flags.InstanceFlags,
[]cli.Flag{
&cli.IntFlag{
Name: "limit",
Usage: "at most this many rounds are read, newest first, before folding to one row per conversation; 0 for the OAP's default",
Value: 0,
},
},
),
Before: interceptor.BeforeChain(
interceptor.DurationInterceptor,
interceptor.ParseService(true),
interceptor.ParseInstance(false),
),
Action: func(ctx *cli.Context) error {
duration := api.Duration{
Start: ctx.String("start"),
End: ctx.String("end"),
Step: ctx.Generic("step").(*model.StepEnumValue).Selected,
}
condition := &api.ConversationListCondition{
Service: &api.ServiceCondition{ServiceName: ctx.String("service-name")},
Instance: instanceCondition(ctx),
}
if limit := ctx.Int("limit"); limit > 0 {
condition.Limit = &limit
}

list, err := aiagent.ListConversations(ctx.Context, condition, duration)
if err != nil {
return err
}
return display.Display(ctx.Context, &displayable.Displayable{Data: list, Condition: condition, Duration: duration})
},
}

// instanceCondition names the sender when "--instance-name" (or "--instance-id",
// resolved to the name by the interceptor) was given.
func instanceCondition(ctx *cli.Context) *api.InstanceCondition {
name := ctx.String("instance-name")
if name == "" {
return nil
}
return &api.InstanceCondition{ServiceName: ctx.String("service-name"), InstanceName: name}
}
Loading
Loading