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
145 changes: 113 additions & 32 deletions backend/modules/soar/executor/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ type LLMStreamer interface {

// LLM is one implementation backing two node types:
// - llm_enrich (kind=enrichment): drives the SOC-AI agent with a prompt and
// returns the final message parsed as JSON — becomes ancestor context for
// downstream nodes.
// returns its final message normalized to {"result": ...} — becomes
// ancestor context for downstream nodes.
// - llm_action (kind=executor): drives the SOC-AI agent with a prompt so it
// can use its own tools (list hosts, run commands, page oncall, etc.) and
// succeeds when the stream ends on a `final` event.
Expand All @@ -33,13 +33,26 @@ type LLM struct {
typ string
}

// NewLLMEnrich registers a node type that expects a JSON `final` payload.
// NewLLMEnrich registers a node type that normalizes its output to {"result": ...}.
func NewLLMEnrich(c LLMStreamer) *LLM { return &LLM{client: c, typ: "llm_enrich"} }

// NewLLMAction registers a node type that treats the `final` payload as free
// text and only cares whether the stream ended cleanly.
func NewLLMAction(c LLMStreamer) *LLM { return &LLM{client: c, typ: "llm_action"} }

// enrichSystemPrompt is appended to the task of every llm_enrich execution.
// It pins the output shape; the backend then enforces it in
// normalizeEnrichmentOutput, so downstream nodes may always rely on
// $(<nodeId>.result). Keep it in English regardless of the flow's lang —
// models follow a contract more reliably in their training language.
const enrichSystemPrompt = `OUTPUT CONTRACT (mandatory, overrides any conflicting instruction above):
Respond with EXACTLY ONE JSON object and nothing else - no prose before or after it, no markdown fences.
The object MUST contain a "result" property holding your complete finding:
{"result": ...}
- "result" may be a string, a JSON object, or a JSON array.
- You may add a few sibling properties (e.g. "confidence").
Downstream automation resolves <this-node-id>.result from your object verbatim.`

func (l *LLM) Type() string { return l.typ }

type llmParams struct {
Expand Down Expand Up @@ -68,8 +81,18 @@ func (l *LLM) Execute(ctx context.Context, exec *domain.SoarExecution) (json.Raw
return nil, errors.New("soar llm: prompt is required")
}

// The SOC-AI client takes a single task body, so the enrichment output
// contract travels inside the task. It is mandatory for this node type:
// downstream nodes resolve $(<nodeId>.result) against the normalized
// output. llm_action leaves the task untouched — it only cares that the
// agent finished cleanly.
task := p.Prompt
if exec.Kind == domain.NodeKindEnrichment {
task = p.Prompt + "\n\n" + enrichSystemPrompt
}

body, err := json.Marshal(map[string]any{
"task": p.Prompt,
"task": task,
"page": defaultString(p.Page, "soar"),
"lang": defaultString(p.Lang, "en"),
"history": p.History,
Expand Down Expand Up @@ -106,11 +129,7 @@ func (l *LLM) Execute(ctx context.Context, exec *domain.SoarExecution) (json.Raw
// structured to hand downstream.
return nil, nil
}
output, err := extractJSONOutput(finalRaw)
if err != nil {
return nil, fmt.Errorf("soar llm enrichment: final is not JSON: %w", err)
}
return output, nil
return normalizeEnrichmentOutput(finalRaw)
}

// drainSSE walks a text/event-stream body and returns the concatenated event
Expand Down Expand Up @@ -182,34 +201,96 @@ func parseSSEFrame(frame []byte) (event string, data string) {
return event, dataBuf.String()
}

// extractJSONOutput accepts a few final-message shapes the SOC-AI agent tends
// to produce: bare JSON, a `content` field inside a JSON envelope, or a JSON
// blob wrapped in a ```json fence.
func extractJSONOutput(finalData string) (json.RawMessage, error) {
trimmed := strings.TrimSpace(finalData)
// normalizeEnrichmentOutput is the backend-side half of the enrichment
// contract: whatever the model returns, the node output is ALWAYS a JSON
// object whose "result" property carries the finding, so downstream nodes can
// unconditionally reference $(<nodeId>.result).
//
// - JSON object already carrying "result" -> passed through unchanged
// (siblings such as "confidence" survive).
// - JSON object without "result" -> encapsulated: the whole
// object becomes the "result" value.
// - JSON array or JSON scalar -> encapsulated as "result".
// - JSON hidden in a {"content": "..."} envelope
// or a ``` fence (model/transport quirk) -> unwrapped first, then
// re-run through the same rules.
// - plain text (contract ignored) -> {"result": "<text>"}.
func normalizeEnrichmentOutput(finalRaw string) (json.RawMessage, error) {
trimmed := strings.TrimSpace(finalRaw)
if trimmed == "" {
return nil, errors.New("empty final message")
return nil, errors.New("soar llm enrichment: empty final message")
}

if raw, ok := tryJSON(trimmed); ok {
// Envelope { "content": "..." } — unwrap and retry.
var env struct {
Content string `json:"content"`
}
if err := json.Unmarshal(raw, &env); err == nil && strings.TrimSpace(env.Content) != "" {
if inner, ok := tryJSON(strings.TrimSpace(env.Content)); ok {
return inner, nil
}
if fenced, ok := stripJSONFence(env.Content); ok {
return fenced, nil
}
return nil, fmt.Errorf("content is not JSON: %s", truncate(env.Content, 200))
}
return raw, nil
return finishFromJSON(unwrapEnvelope(raw))
}
if fenced, ok := stripJSONFence(trimmed); ok {
return fenced, nil
return finishFromJSON(unwrapEnvelope(fenced))
}
// No JSON at all: the model answered in prose. Still succeed — the whole
// text becomes "result". The contract prompt exists to avoid this branch.
quoted, err := json.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("soar llm enrichment: encapsulate result: %w", err)
}
return appendJSONValue(quoted), nil
}

// finishFromJSON passes a JSON value through when it is already an object
// carrying "result"; otherwise it encapsulates the value under "result".
func finishFromJSON(raw json.RawMessage) (json.RawMessage, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err == nil {
if _, has := m["result"]; has {
return raw, nil
}
}
return appendJSONValue(raw), nil
}

// unwrapEnvelope resolves a JSON value to its payload: a {"content": "..."}
// envelope whose content is JSON (possibly fenced) is unwrapped to that inner
// value; content that is plain prose becomes a JSON string. The model
// sometimes wraps its answer in the chat-message envelope instead of sending
// the object directly.
func unwrapEnvelope(raw json.RawMessage) json.RawMessage {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return raw // array or scalar — nothing to unwrap
}
c, ok := m["content"]
if !ok {
return raw
}
var cs string
if err := json.Unmarshal(c, &cs); err != nil {
return raw // content is not a string — leave the envelope as-is
}
return nil, fmt.Errorf("not JSON: %s", truncate(trimmed, 200))
cs = strings.TrimSpace(cs)
if cs == "" {
return raw
}
if inner, isJSON := tryJSON(cs); isJSON {
return inner
}
if fenced, isJSON := stripJSONFence(cs); isJSON {
return fenced
}
quoted, err := json.Marshal(cs)
if err != nil {
return raw
}
return quoted
}

// appendJSONValue wraps a JSON value under "result", keeping objects, arrays
// and scalars as real JSON values (not escaped strings).
func appendJSONValue(raw json.RawMessage) json.RawMessage {
out := make([]byte, 0, len(raw)+12)
out = append(out, `{"result":`...)
out = append(out, raw...)
out = append(out, '}')
return out
}

func tryJSON(s string) (json.RawMessage, bool) {
Expand All @@ -231,7 +312,7 @@ func stripJSONFence(s string) (json.RawMessage, bool) {
return tryJSON(strings.TrimSpace(trimmed))
}

func defaultString(s, fallback string) string {
func defaultString(s string, fallback string) string {
if s == "" {
return fallback
}
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/features/soar/components/LLMParamsEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { FlowNode } from '../types/soar.types'
import { InsertFieldMenu } from './InsertFieldMenu'

interface Props {
nodeId: string
nodes: Record<string, FlowNode>
params: unknown
readOnly?: boolean
/** executor: 'llm_enrich' | 'llm_action' — the hint differs per kind. */
executor: string
onChange: (params: { prompt?: string }) => void
}

// llm_enrich / llm_action params hold a single free-text prompt. This editor
// replaces the raw JSON textarea for those nodes — users see one text box,
// never `{"prompt": ...}`. For llm_enrich the backend injects the mandatory
// output contract (a JSON object with a `result` property) into the task
// itself, so nothing about the return shape is configured here; the hint
// just tells the user how children will read it.
export function LLMParamsEditor({ nodeId, nodes, params, readOnly, executor, onChange }: Props) {
const { t } = useTranslation()
const promptRef = useRef<HTMLTextAreaElement>(null)
const [prompt, setPrompt] = useState(() => extractPrompt(params))

useEffect(() => {
setPrompt(extractPrompt(params))
}, [params, nodeId])

const isEnrich = executor === 'llm_enrich'

const commit = () => {
const trimmed = prompt.trim()
onChange({ prompt: trimmed })
}

const insertIntoPrompt = (token: string) => {
const el = promptRef.current
const cur = prompt
const start = el?.selectionStart ?? cur.length
const end = el?.selectionEnd ?? cur.length
const next = cur.slice(0, start) + token + cur.slice(end)
setPrompt(next)
requestAnimationFrame(() => {
const el2 = promptRef.current
if (!el2) return
el2.focus()
const pos = start + token.length
el2.setSelectionRange(pos, pos)
})
}

return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<label className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{t('soar.editor.canvas.llm.prompt')}
</label>
{!readOnly && (
<InsertFieldMenu nodes={nodes} currentNodeId={nodeId} onInsert={insertIntoPrompt} />
)}
</div>
<textarea
ref={promptRef}
value={prompt}
readOnly={readOnly}
onChange={(e) => setPrompt(e.target.value)}
onBlur={commit}
rows={8}
placeholder={
isEnrich
? 'Analyze this alert and classify it: $(alert.name)'
: 'Investigate $(alert.target.host) and page on-call if needed'
}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{isEnrich && (
<p className="text-[10px] leading-snug text-muted-foreground">
{t('soar.editor.canvas.llm.hint', { nodeId })}
</p>
)}
</div>
)
}

function extractPrompt(params: unknown): string {
if (!params || typeof params !== 'object') return ''
const p = (params as { prompt?: unknown }).prompt
return typeof p === 'string' ? p : ''
}
14 changes: 13 additions & 1 deletion frontend/src/features/soar/components/NodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HttpParamsEditor } from './HttpParamsEditor'
import { IncidentParamsEditor } from './IncidentParamsEditor'
import { InsertFieldMenu } from './InsertFieldMenu'
import { MailParamsEditor } from './MailParamsEditor'
import { LLMParamsEditor } from './LLMParamsEditor'

interface Props {
nodeId: string
Expand Down Expand Up @@ -243,7 +244,18 @@ export function NodeInspector({ nodeId, node, nodes, readOnly, onRename, onChang
/>
)}

{node.executor !== 'shell' && node.executor !== 'conditional' && node.executor !== 'http' && node.executor !== 'incident' && node.executor !== 'mail' && (
{(node.executor === 'llm_enrich' || node.executor === 'llm_action') && (
<LLMParamsEditor
nodeId={nodeId}
nodes={nodes}
params={node.params}
readOnly={readOnly}
executor={node.executor}
onChange={(next) => onChange({ params: next })}
/>
)}

{node.executor !== 'shell' && node.executor !== 'conditional' && node.executor !== 'http' && node.executor !== 'incident' && node.executor !== 'mail' && node.executor !== 'llm_enrich' && node.executor !== 'llm_action' && (
<Field label={t('soar.editor.canvas.paramsJson')}>
{!readOnly && (
<div className="mb-1 flex flex-wrap items-center gap-1.5">
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/features/soar/lib/ancestors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { enrichmentAncestors } from './ancestors'
import type { FlowNode } from '../types/soar.types'

const enrich = (executor: string): FlowNode => ({
kind: 'enrichment',
executor,
onSuccess: ['child'],
})

describe('enrichmentAncestors static fields', () => {
const nodes: Record<string, FlowNode> = {
llm1: enrich('llm_enrich'),
geo: enrich('http'),
child: { kind: 'executor', executor: 'shell', command: 'echo' },
}

it('advertises result for llm_enrich parents (backend-guaranteed shape)', () => {
const out = enrichmentAncestors(nodes, 'child')
expect(out.find((a) => a.nodeId === 'llm1')?.fields).toEqual(['result'])
})

it('leaves http parents runtime-dependent (no static fields)', () => {
const out = enrichmentAncestors(nodes, 'child')
expect(out.find((a) => a.nodeId === 'geo')?.fields).toEqual([])
})
})
6 changes: 5 additions & 1 deletion frontend/src/features/soar/lib/ancestors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export function enrichmentAncestors(nodes: Record<string, FlowNode>, target: str
const n = nodes[id]
if (!n) continue
if (n.kind === 'enrichment') {
out.push({ nodeId: id, executor: n.executor, fields: [] })
// llm_enrich output is always normalized to {"result": ...} by the
// backend, so "result" is statically known; other executors' output
// shapes stay runtime-dependent (empty fields = user types the path).
const fields = n.executor === 'llm_enrich' ? ['result'] : []
out.push({ nodeId: id, executor: n.executor, fields })
}
for (const parent of reverse.get(id) ?? []) queue.push(parent)
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/shared/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -4795,6 +4795,10 @@
"description": "Beschreibung",
"descriptionPlaceholder": "Optionaler Kontext für Bearbeiter. Vorlagen wie $(alert.name) werden interpoliert."
},
"llm": {
"prompt": "Prompt",
"hint": "Das Modell ist angewiesen, immer mit einem einzigen JSON-Objekt mit der Eigenschaft \"result\" zu antworten; die Backend garantiert dieses Format, selbst wenn das Modell abweicht. Kindknoten greifen per $({{nodeId}}.result) oder konkreten Feldern zu."
},
"mail": {
"to": "An (kommagetrennt)",
"cc": "CC (kommagetrennt, optional)",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/shared/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5181,6 +5181,10 @@
"description": "Description",
"descriptionPlaceholder": "Optional context for responders. Templates like $(alert.name) are interpolated."
},
"llm": {
"prompt": "Prompt",
"hint": "The model is instructed to always answer with a single JSON object carrying a \"result\" property. The backend guarantees this shape even if the model deviates. Child nodes reference it via $({{nodeId}}.result) or specific fields."
},
"mail": {
"to": "To (comma-separated)",
"cc": "CC (comma-separated, optional)",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/shared/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4917,6 +4917,10 @@
"description": "Descripción",
"descriptionPlaceholder": "Contexto opcional para los responsables. Plantillas como $(alert.name) se interpolan."
},
"llm": {
"prompt": "Prompt",
"hint": "El modelo siempre responde con un único objeto JSON que incluye la propiedad \"result\"; el backend garantiza ese formato aunque el modelo se desvíe. Los nodos hijos la consultan con $({{nodeId}}.result) o campos específicos."
},
"mail": {
"to": "Para (separados por coma)",
"cc": "CC (separados por coma, opcional)",
Expand Down
Loading
Loading