This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/ai-history-and-enhancement
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 922b5e0a6eec41e764abe3f403674d86171232fa
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 19:09:44 2026 +0800

    feat(ai): network profiling reads out as text, not a drifting tab-handoff
    
    A tab-handoff re-queries live, so the view would drift from the data the 
agent
    captured — breaking the snapshot contract. Remove it: analyze_profiling now
    handles 'network' by reading the process-conversation graph
    (getProcessTopology, one instance) and returning a TEXT read-out — process +
    conversation counts and the top conversations — or an honest "needs a Rover
    eBPF agent, none reporting" when empty. The proposal card's network
    tab-handoff message is dropped (all types now say "ask me to analyze"). The
    skills prompt states the general rule: never point the user at a live tab 
for
    a result; a block replays the snapshot, or you describe in text what you 
read.
    
    Validated live: network analyze on agent::songs (no rover on the demo)
    returned the honest no-data text, which the agent relayed.
---
 apps/bff/src/ai/resources/prompts/skills.md   |  4 +--
 apps/bff/src/ai/resources/tools/triggers.yaml |  2 +-
 apps/bff/src/ai/skill/triggers/tools.ts       | 21 +++++++----
 apps/bff/src/logic/oap/profiling.ts           | 50 +++++++++++++++++++++++++++
 apps/ui/src/ai/ChatProposalBlock.vue          |  8 +----
 apps/ui/src/i18n/locales/en.json              |  1 -
 6 files changed, 68 insertions(+), 18 deletions(-)

diff --git a/apps/bff/src/ai/resources/prompts/skills.md 
b/apps/bff/src/ai/resources/prompts/skills.md
index f524713..c24e70b 100644
--- a/apps/bff/src/ai/resources/prompts/skills.md
+++ b/apps/bff/src/ai/resources/prompts/skills.md
@@ -71,5 +71,5 @@ SUB-PAGE TOOLS — mount an interactive feature view as a card 
(opens the full p
 
 TRIGGERS skill — gated profiling (propose_profiling) + analysis 
(analyze_profiling)
 - You are READ-ONLY except for propose_profiling. When metrics + pod logs 
cannot localise a cause and a PROFILE would confirm a specific hypothesis, call 
propose_profiling — it presents a decision card (your analysed cause, why 
profiling, what you expect); the USER approves it in a popout; it runs only 
after approval; you analyse the result in a LATER turn. Never assume it ran, 
and never fabricate its output.
-- Pick the profilingType by READING, not assuming: 
kb_layer_capabilities(layer).components says which of 
trace/async/pprof/ebpf/network the layer supports; async-profiler is JVM-only 
and pprof is Go-only (read the instance language via kb_resolve_scope_drill to 
choose), trace + eBPF are language-agnostic, network profiling yields a process 
graph. The tool re-validates the type against the layer and resolves target 
instances.
-- analyze_profiling(layer, service, profilingType) is READ-only — call it in 
that later turn (or whenever the user asks to analyse an existing profile) to 
render the flame graph of the most recent completed task. The reply lists the 
hottest self-time frames; use them to name the cause. If it says no data was 
collected yet, tell the user to let the task finish — do NOT retry immediately.
+- Pick the profilingType by READING, not assuming: 
kb_layer_capabilities(layer).components says which of 
trace/async/pprof/ebpf/network the layer supports; async-profiler is JVM-only 
and pprof is Go-only (read the instance language via kb_resolve_scope_drill to 
choose), trace + eBPF are language-agnostic, network profiling yields a 
process-conversation graph. The tool re-validates the type against the layer 
and resolves target instances.
+- analyze_profiling(layer, service, profilingType) is READ-only — call it in 
that later turn (or whenever the user asks to analyse an existing profile). 
trace/pprof/async/ebpf render a flame graph inline and the reply lists the 
hottest self-time frames (use them to name the cause); network has no flame, so 
it returns a TEXT read-out of the process-conversation graph — relay that. 
Never point the user at a live tab for a result: a tab re-queries and drifts 
from the data you captured — eit [...]
diff --git a/apps/bff/src/ai/resources/tools/triggers.yaml 
b/apps/bff/src/ai/resources/tools/triggers.yaml
index c219b81..0922521 100644
--- a/apps/bff/src/ai/resources/tools/triggers.yaml
+++ b/apps/bff/src/ai/resources/tools/triggers.yaml
@@ -56,6 +56,6 @@ analyze_profiling:
     service: >-
       service NAME to analyze the profile for
     profilingType: >-
-      which profiling result to read — trace (in-process sampling), pprof 
(Go), async (Java async-profiler), or ebpf (on/off-CPU)
+      which profiling result to read — trace (in-process sampling), pprof 
(Go), async (Java async-profiler), ebpf (on/off-CPU), or network (renders a 
text read-out of the process-conversation graph, not a flame)
     taskId: >-
       a specific task id to analyze; omit to use the most recent task of this 
type
diff --git a/apps/bff/src/ai/skill/triggers/tools.ts 
b/apps/bff/src/ai/skill/triggers/tools.ts
index fde48ad..b832986 100644
--- a/apps/bff/src/ai/skill/triggers/tools.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.ts
@@ -30,7 +30,7 @@ import type { StructuredToolInterface } from 
'@langchain/core/tools';
 import type { AiRequestContext } from '../../context.js';
 import type { ProfilingProposalType } from '../../types.js';
 import { toolPrompt } from '../../resources/loader.js';
-import { analyzeProfiling, listServiceInstances } from 
'../../../logic/oap/profiling.js';
+import { analyzeProfiling, analyzeNetworkProfiling, listServiceInstances } 
from '../../../logic/oap/profiling.js';
 import type { ProfilingAnalysis } from '../../../logic/oap/profiling.js';
 import { layerCapabilities } from '../../../logic/layers/capabilities.js';
 
@@ -140,11 +140,7 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
         rationale,
         expectation,
       });
-      const analyseNote =
-        profilingType === 'network'
-          ? 'once it has collected data, its result appears as a 
process-conversation topology'
-          : 'once it has collected data, call analyze_profiling to render the 
flame';
-      return `Proposed a ${profilingType}-profiling task to the user as a 
decision card. It is NOT running — the user must approve it. Do not analyze 
now; stop here, tell the user to approve it, and that ${analyseNote}.`;
+      return `Proposed a ${profilingType}-profiling task to the user as a 
decision card. It is NOT running — the user must approve it. Do not analyze 
now; stop here, tell the user to approve it, and that once it has collected 
data you will call analyze_profiling to read the result.`;
     },
     {
       name: 'propose_profiling',
@@ -168,6 +164,17 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
   const at = toolPrompt('triggers', 'analyze_profiling');
   const analyze = tool(
     async ({ layer, service, profilingType, taskId }): Promise<string> => {
+      // Network profiling has no flame — its result is a process-conversation
+      // graph. Read it out as TEXT rather than hand off to a live tab (which 
would
+      // drift from what we read).
+      if (profilingType === 'network') {
+        const s = await analyzeNetworkProfiling(ctx.opts, layer, service, 
ctx.window);
+        if (!s.reachable) return `Could not read the network-profiling result 
for ${service}: ${s.error ?? 'unreachable'}.`;
+        if (!s.processCount) {
+          return `No process-conversation data for ${service} — network 
profiling needs a Rover eBPF agent, and none is reporting here.`;
+        }
+        return `Network profiling for ${service} (instance ${s.instanceName}): 
${s.processCount} process(es) in ${s.conversationCount} conversation(s). Top: 
${s.conversations.join('; ')}.`;
+      }
       const a = await analyzeProfiling({ opts: ctx.opts, profilingType, 
layerKey: layer, service, taskId });
       ctx.emitProfiling({
         title: `Profiling — ${service} (${profilingType})`,
@@ -195,7 +202,7 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
       schema: z.object({
         layer: z.string().describe(at.p('layer')),
         service: z.string().describe(at.p('service')),
-        profilingType: z.enum(['trace', 'pprof', 'async', 
'ebpf']).describe(at.p('profilingType')),
+        profilingType: z.enum(['trace', 'pprof', 'async', 'ebpf', 
'network']).describe(at.p('profilingType')),
         taskId: z.string().optional().describe(at.p('taskId')),
       }),
     },
diff --git a/apps/bff/src/logic/oap/profiling.ts 
b/apps/bff/src/logic/oap/profiling.ts
index 8a0d117..264cca9 100644
--- a/apps/bff/src/logic/oap/profiling.ts
+++ b/apps/bff/src/logic/oap/profiling.ts
@@ -221,6 +221,56 @@ export async function listServiceInstances(
   return (data.instances ?? []).map((i) => ({ id: i.id, name: i.name, 
language: i.language ?? null }));
 }
 
+const GET_PROCESS_TOPOLOGY = /* GraphQL */ `
+  query AiProcessTopology($serviceInstanceId: ID!, $duration: Duration!) {
+    topology: getProcessTopology(serviceInstanceId: $serviceInstanceId, 
duration: $duration) {
+      nodes { id name }
+      calls { source target }
+    }
+  }
+`;
+
+export interface NetworkProfilingSummary {
+  instanceName: string | null;
+  processCount: number;
+  conversationCount: number;
+  /** "procA → procB" for the first conversations, for the agent to read out. 
*/
+  conversations: string[];
+  reachable: boolean;
+  error?: string;
+}
+
+/** Network profiling's result is a process-conversation graph, not a flame. 
It is
+ *  summarised to TEXT (never handed to a live tab, which would drift from 
what the
+ *  agent read). Empty ⇒ no Rover eBPF agent is reporting. */
+export async function analyzeNetworkProfiling(
+  opts: GraphqlOptions,
+  layerKey: string,
+  service: string,
+  window: { start: string; end: string; step: string },
+): Promise<NetworkProfilingSummary> {
+  const empty: NetworkProfilingSummary = { instanceName: null, processCount: 
0, conversationCount: 0, conversations: [], reachable: true };
+  try {
+    const serviceId = await resolveServiceId(opts, layerKey, service);
+    if (!serviceId) return { ...empty, reachable: false, error: `Unknown 
service "${service}" in layer ${layerKey}.` };
+    const insts = await listServiceInstances(opts, serviceId, window);
+    if (!insts.length) return empty;
+    const inst = insts[0];
+    const data = await graphqlPost<{ topology: { nodes: Array<{ id: string; 
name: string }>; calls: Array<{ source: string; target: string }> } | null }>(
+      opts,
+      GET_PROCESS_TOPOLOGY,
+      { serviceInstanceId: inst.id, duration: { start: window.start, end: 
window.end, step: window.step } },
+    );
+    const nodes = data.topology?.nodes ?? [];
+    const calls = data.topology?.calls ?? [];
+    const nameById = new Map(nodes.map((n) => [n.id, n.name]));
+    const conversations = calls.slice(0, 12).map((c) => 
`${nameById.get(c.source) ?? c.source} → ${nameById.get(c.target) ?? 
c.target}`);
+    return { instanceName: inst.name, processCount: nodes.length, 
conversationCount: calls.length, conversations, reachable: true };
+  } catch (err) {
+    return { ...empty, reachable: false, error: err instanceof Error ? 
err.message : String(err) };
+  }
+}
+
 const ENCODED_ID = /^[A-Za-z0-9+/=]+\.\d+$/;
 
 async function resolveServiceId(opts: GraphqlOptions, layerKey: string, 
serviceArg: string): Promise<string | null> {
diff --git a/apps/ui/src/ai/ChatProposalBlock.vue 
b/apps/ui/src/ai/ChatProposalBlock.vue
index 4e2833e..50549d6 100644
--- a/apps/ui/src/ai/ChatProposalBlock.vue
+++ b/apps/ui/src/ai/ChatProposalBlock.vue
@@ -103,12 +103,6 @@ function dismiss(): void {
 }
 
 const typeLabel = computed<string>(() => 
TYPE_LABEL[props.block.spec.profilingType]);
-// network profiling has no flame analysis — its result is a process graph.
-const approvedMessage = computed<string>(() =>
-  props.block.spec.profilingType === 'network'
-    ? t('Network profiling started — the process-conversation graph appears in 
the Network Profiling view.')
-    : t('Profiling started — ask me to analyze the results once it has 
collected data.'),
-);
 // One target line adapted to the type: endpoint for trace, resolved instances
 // for async/pprof/network, the CPU target for eBPF.
 const targetDetail = computed<string>(() => {
@@ -141,7 +135,7 @@ const targetDetail = computed<string>(() => {
       </button>
     </div>
     <p v-else-if="block.status === 'approved'" class="prop__out">
-      {{ approvedMessage }}
+      {{ t('Profiling started — ask me to analyze the results once it has 
collected data.') }}
     </p>
     <p v-else-if="block.status === 'dismissed'" class="prop__out">{{ 
t('Dismissed.') }}</p>
     <p v-else class="prop__out prop__out--err">{{ t('Could not start 
profiling.') }} {{ block.error }}</p>
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 8523221..b684690 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1648,7 +1648,6 @@
   "all endpoints": "all endpoints",
   "Could not read the profile.": "Could not read the profile.",
   "No profile data was collected in this task yet.": "No profile data was 
collected in this task yet.",
-  "Network profiling started — the process-conversation graph appears in the 
Network Profiling view.": "Network profiling started — the process-conversation 
graph appears in the Network Profiling view.",
   "Cause": "Cause",
   "Why profiling": "Why profiling",
   "Expected": "Expected",

Reply via email to