diff --git a/docs/content/investigations.md b/docs/content/investigations.md index 3861fb54..992fac50 100644 --- a/docs/content/investigations.md +++ b/docs/content/investigations.md @@ -74,6 +74,7 @@ the token falls out of the address bar. The launcher stays alive in the terminal 3. **Spawn the agent.** Claude is launched with that `mcp.json` plus a system prompt that points the agent at the `investigation` playbook. The agent is told nothing product-specific in prose; the playbooks carry the procedural knowledge. + The form's optional **Playbook** picker overrides this: when the operator selects a playbook (a release-verification runbook, say), the system prompt points the agent at that playbook instead, and the guided flow, including the closing `capture_offer` step, is skipped. The session header shows which playbook was selected. 4. **Walk the playbook.** The agent calls `list_playbooks`, picks a matching domain playbook, and walks it: read step description, make suggested calls, call `step_complete` with findings and the matching goto. The activity panel renders every tool call live. diff --git a/frontend/app/(main)/investigations/new/page.tsx b/frontend/app/(main)/investigations/new/page.tsx index 97b8a91d..e3e7f355 100644 --- a/frontend/app/(main)/investigations/new/page.tsx +++ b/frontend/app/(main)/investigations/new/page.tsx @@ -58,6 +58,7 @@ function InvestigationsHomeInner() { inputs: sub.inputs, prom: sub.prom, auto: sub.auto || undefined, + playbook: sub.playbook, }); router.push(`/investigations/?id=${encodeURIComponent(inv.id)}`); } catch (e) { diff --git a/frontend/components/investigations/InvestigationForm.test.tsx b/frontend/components/investigations/InvestigationForm.test.tsx index dc727423..aacbc549 100644 --- a/frontend/components/investigations/InvestigationForm.test.tsx +++ b/frontend/components/investigations/InvestigationForm.test.tsx @@ -144,4 +144,42 @@ describe("InvestigationForm", () => { expect.objectContaining({ auto: false }), ); }); + + describe("playbook selection", () => { + const synced = { status: "synced", reason: "" } as const; + function setup() { + vi.spyOn(api, "getProfileInputs").mockResolvedValue([ + { id: "notes", label: "Notes", type: "textarea", optional: true, placeholder: "enter notes" }, + ]); + vi.spyOn(api, "getConnections").mockResolvedValue({ slack: false, incidentio: false, slack_channel_prefix: "" }); + vi.spyOn(api, "listPlaybooks").mockResolvedValue([ + { id: "investigation", source: "system", locked: true, nodeCount: 1, yaml: "", syncState: synced, type: "general" }, + { id: "release_verification", symptom: "Verify a release", source: "user", nodeCount: 1, yaml: "", syncState: synced, type: "general" }, + ]); + } + + it("submits without a playbook by default", async () => { + setup(); + const onSubmit = vi.fn(); + render(); + await screen.findByPlaceholderText("enter notes"); + await screen.findByRole("option", { name: /release_verification/ }); + + fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: undefined })); + }); + + it("submits the chosen playbook id and hides locked entries", async () => { + setup(); + const onSubmit = vi.fn(); + render(); + await screen.findByPlaceholderText("enter notes"); + await screen.findByRole("option", { name: /release_verification/ }); + expect(screen.queryByRole("option", { name: /^investigation/ })).toBeNull(); + + fireEvent.change(screen.getByLabelText(/Playbook/i), { target: { value: "release_verification" } }); + fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: "release_verification" })); + }); + }); }); diff --git a/frontend/components/investigations/InvestigationForm.tsx b/frontend/components/investigations/InvestigationForm.tsx index 46f23f8c..9f64b3a6 100644 --- a/frontend/components/investigations/InvestigationForm.tsx +++ b/frontend/components/investigations/InvestigationForm.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { api, type InputSchema, type PromOverride } from "@/lib/api"; +import { api, type InputSchema, type PlaybookListItem, type PromOverride } from "@/lib/api"; +import { selectablePlaybooks } from "@/lib/playbook-select"; import { ArrowRightIcon } from "@/components/shared/Icons"; import { Spinner } from "@/components/shared/Spinner"; import { TextInput } from "@/components/inputs/TextInput"; @@ -14,6 +15,9 @@ export type FormSubmission = { inputs: Record>; prom: PromOverride; auto: boolean; + // playbook: id of the playbook the operator picked, or undefined for + // the profile's guided investigation flow. + playbook?: string; }; type Props = { @@ -24,6 +28,8 @@ export function InvestigationForm({ onSubmit }: Props) { const [schema, setSchema] = useState(null); const [values, setValues] = useState>>({}); const [auto, setAuto] = useState(false); + const [playbooks, setPlaybooks] = useState([]); + const [playbook, setPlaybook] = useState(""); // Prom override panel state — unchanged from today. const [showPromOverrides, setShowPromOverrides] = useState(false); @@ -34,6 +40,12 @@ export function InvestigationForm({ onSubmit }: Props) { useEffect(() => { api.getProfileInputs().then(setSchema).catch(() => setSchema([])); + // The picker degrades to "default flow only" when the catalog + // can't be fetched; the operator can still start a session. + api + .listPlaybooks() + .then((items) => setPlaybooks(selectablePlaybooks(items))) + .catch(() => setPlaybooks([])); // Pre-populate prom override fields with the profile's defaults so the // operator sees what's currently configured. Functional setters so a // late-arriving fetch can't overwrite values the operator typed while @@ -64,7 +76,7 @@ export function InvestigationForm({ onSubmit }: Props) { const portNum = parseInt(promPort, 10); if (!Number.isNaN(portNum) && portNum > 0) prom.port = portNum; } - onSubmit({ inputs: values, prom, auto }); + onSubmit({ inputs: values, prom, auto, playbook: playbook || undefined }); } function setValue(id: string, next: Record) { @@ -125,6 +137,28 @@ export function InvestigationForm({ onSubmit }: Props) { } })} + {playbooks.length > 0 && ( + + Playbook + + Walk a specific playbook instead of the guided investigation flow. + + setPlaybook(e.target.value)} + className={inputClass + " mt-2"} + > + Guided investigation (default) + {playbooks.map((p) => ( + + {p.id} + {p.symptom ? ` — ${p.symptom}` : ""} + + ))} + + + )} + )} + {investigation.playbook && ( + + playbook: {investigation.playbook} + + )} > ); } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index c0336c1c..a652cb42 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -327,6 +327,10 @@ export type PreflightRequest = { // Omitted on the wire when false thanks to `auto,omitempty` on the // server-side struct. auto?: boolean; + // playbook: optional playbook id to walk instead of the profile's + // guided investigation flow. Omitted when the operator keeps the + // default. + playbook?: string; }; export type LinkedRepo = { @@ -479,6 +483,9 @@ export type Investigation = { slackChannelUrl?: string; notes?: string; label?: string; + // playbook: the id the operator selected at session start; absent + // when the session runs the guided investigation flow. + playbook?: string; mcpConfigPath: string; docsPrefix?: string; sessionDir: string; diff --git a/frontend/lib/playbook-select.test.ts b/frontend/lib/playbook-select.test.ts new file mode 100644 index 00000000..d3aec77b --- /dev/null +++ b/frontend/lib/playbook-select.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { selectablePlaybooks } from "./playbook-select"; +import type { PlaybookListItem } from "./playbook"; + +const synced = { status: "synced", reason: "" } as PlaybookListItem["syncState"]; + +function item(over: Partial & { id: string }): PlaybookListItem { + return { source: "plugin", nodeCount: 1, yaml: "", syncState: synced, ...over }; +} + +describe("selectablePlaybooks", () => { + it("drops locked, disabled and broken entries", () => { + const out = selectablePlaybooks([ + item({ id: "investigation", source: "system", locked: true }), + item({ id: "off", disabled: true }), + item({ id: "bad", source: "broken" }), + item({ id: "ok" }), + ]); + expect(out.map((p) => p.id)).toEqual(["ok"]); + }); + + it("orders by type (investigation first) then id", () => { + const out = selectablePlaybooks([ + item({ id: "z-general", type: "general" }), + item({ id: "b-inv", type: "investigation" }), + item({ id: "a-general", type: "general" }), + item({ id: "untyped" }), + ]); + expect(out.map((p) => p.id)).toEqual(["b-inv", "untyped", "a-general", "z-general"]); + }); +}); diff --git a/frontend/lib/playbook-select.ts b/frontend/lib/playbook-select.ts new file mode 100644 index 00000000..39519ff5 --- /dev/null +++ b/frontend/lib/playbook-select.ts @@ -0,0 +1,20 @@ +import { DEFAULT_PLAYBOOK_TYPE, type PlaybookListItem } from "./playbook"; + +// selectablePlaybooks narrows the playbook catalog to entries an +// operator can start a session against: locked entries are the +// launcher's own metas (the guided entrypoint, closing offer, and +// sub-flows the walker delegates to), and disabled / broken entries +// can't be walked. Investigation-typed playbooks sort first, then +// other types, each group by id. +export function selectablePlaybooks(items: PlaybookListItem[]): PlaybookListItem[] { + const typeOf = (p: PlaybookListItem) => p.type || DEFAULT_PLAYBOOK_TYPE; + const rank = (p: PlaybookListItem) => (typeOf(p) === DEFAULT_PLAYBOOK_TYPE ? 0 : 1); + return items + .filter((p) => !p.locked && !p.disabled && p.source !== "broken") + .sort( + (a, b) => + rank(a) - rank(b) || + typeOf(a).localeCompare(typeOf(b)) || + a.id.localeCompare(b.id), + ); +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8bf7a27f..1a0e6782 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -344,6 +344,12 @@ func (a *apiHandlers) handlePreflight(w http.ResponseWriter, r *http.Request) { return } } + if body.Playbook != "" { + if err := a.validateSelectedPlaybook(body.Playbook); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } // Extract typed values from the generic inputs map. // Canonical input IDs (same for any profile that @@ -473,6 +479,7 @@ func (a *apiHandlers) handlePreflight(w http.ResponseWriter, r *http.Request) { IncidentioMCPEnabled: ioMCPToken != "", LinkedRepos: linked, Profile: a.prof, + Playbook: body.Playbook, PromTarget: promTarget, PromDisabled: promDisabled, ActiveContext: activeContext, @@ -1031,6 +1038,31 @@ type preflightRequest struct { // turn — EnableAuto runs in a goroutine. Operator-side failures are // logged to stderr and the investigation continues in manual mode. Auto bool `json:"auto,omitempty"` + // Playbook is an optional playbook id the session should walk + // instead of the profile's guided investigation flow. Must name a + // loadable, non-disabled playbook from GET /api/playbooks. + Playbook string `json:"playbook,omitempty"` +} + +// validateSelectedPlaybook checks that id names a playbook the session +// can start on: present in the catalog, not disabled, and not one of +// the launcher's locked metas (the guided entrypoint, the closing +// offer, and the sub-flows the walker delegates to are internal, not +// standalone session entrypoints). +func (a *apiHandlers) validateSelectedPlaybook(id string) error { + for _, pb := range a.collectPlaybooks() { + if pb.ID != id { + continue + } + if pb.Disabled { + return fmt.Errorf("playbook %q is disabled", id) + } + if pb.Locked { + return fmt.Errorf("playbook %q is a launcher meta and cannot be selected", id) + } + return nil + } + return fmt.Errorf("playbook %q not found", id) } // promOverrideBody is the per-investigation Prometheus override received diff --git a/internal/server/handlers_start_test.go b/internal/server/handlers_start_test.go index 923b32a6..a9caaa75 100644 --- a/internal/server/handlers_start_test.go +++ b/internal/server/handlers_start_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -10,6 +11,8 @@ import ( "github.com/sourcehawk/triagent/internal/connections" "github.com/sourcehawk/triagent/internal/preflight" "github.com/sourcehawk/triagent/internal/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // newPreflightAPIWithInputsProfile returns an apiHandlers whose prof field is @@ -124,3 +127,83 @@ func TestPreflight_InputsMap_EmptyIsOK(t *testing.T) { t.Fatalf("status=%d, body=%s", rr.Code, rr.Body.String()) } } + +// seedPlaybooks populates the handler's meta cache with the given +// playbooks so preflight's playbook validation has a catalog to check. +func seedPlaybooks(a *apiHandlers, pbs map[string]MetaPlaybook) { + a.metaCache = &metaCache{} + a.metaCache.set(&Meta{Playbooks: pbs}) +} + +func postPreflight(t *testing.T, a *apiHandlers, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/preflight", strings.NewReader(body)) + rr := httptest.NewRecorder() + a.handlePreflight(rr, req) + return rr +} + +func TestPreflight_SelectedPlaybookIsRecordedOnInvestigation(t *testing.T) { + t.Parallel() + a := newPreflightAPIWithInputsProfile(t) + seedPlaybooks(a, map[string]MetaPlaybook{ + "release_verification": {Source: "plugin", Type: "general", YAML: "id: release_verification\nsymptom: x\nentrypoint: n\nnodes:\n n: {description: d}\n"}, + }) + + rr := postPreflight(t, a, `{"inputs": {}, "playbook": "release_verification"}`) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + + var dto InvestigationDTO + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &dto)) + assert.Equal(t, "release_verification", dto.Playbook) + inv := a.manager.Get(dto.ID) + require.NotNil(t, inv) + assert.Equal(t, "release_verification", inv.Playbook) +} + +func TestPreflight_UnknownPlaybookRejected(t *testing.T) { + t.Parallel() + a := newPreflightAPIWithInputsProfile(t) + seedPlaybooks(a, map[string]MetaPlaybook{}) + + rr := postPreflight(t, a, `{"inputs": {}, "playbook": "nope"}`) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "nope", "error must name the offending playbook") +} + +func TestPreflight_DisabledPlaybookRejected(t *testing.T) { + t.Parallel() + a := newPreflightAPIWithInputsProfile(t) + seedPlaybooks(a, map[string]MetaPlaybook{ + "off": {Source: "plugin", Type: "general", YAML: "id: off\nactive: false\nsymptom: x\nentrypoint: n\nnodes:\n n: {description: d}\n"}, + }) + + rr := postPreflight(t, a, `{"inputs": {}, "playbook": "off"}`) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "off") +} + +func TestPreflight_LockedPlaybookRejected(t *testing.T) { + t.Parallel() + a := newPreflightAPIWithInputsProfile(t) + seedPlaybooks(a, map[string]MetaPlaybook{ + "investigation": {Source: "system", Locked: true, Type: "general", YAML: "id: investigation\nsymptom: x\nentrypoint: n\nnodes:\n n: {description: d}\n"}, + }) + + rr := postPreflight(t, a, `{"inputs": {}, "playbook": "investigation"}`) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "investigation") +} + +func TestPreflight_NoPlaybookLeavesInvestigationUnset(t *testing.T) { + t.Parallel() + a := newPreflightAPIWithInputsProfile(t) + // No meta cache seeded: the default path must not consult the catalog. + + rr := postPreflight(t, a, `{"inputs": {}}`) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var dto InvestigationDTO + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &dto)) + assert.Empty(t, dto.Playbook) + assert.NotContains(t, rr.Body.String(), `"playbook"`, "unset playbook must be omitted from the wire DTO") +} diff --git a/internal/server/manager.go b/internal/server/manager.go index 0efa501b..f44d94cb 100644 --- a/internal/server/manager.go +++ b/internal/server/manager.go @@ -82,6 +82,11 @@ type Investigation struct { IncidentioMCPEnabled bool LinkedRepos []repos.LinkedRepo Profile *profile.Profile // investigation profile (prompt content + playbook IDs) + // Playbook is the playbook id the operator selected at preflight. + // When set, the session walks that playbook instead of the + // profile's guided investigation flow. Empty for the default flow + // and for signal-watch spawns. + Playbook string // PromTarget is the per-investigation Prometheus port-forward target, // resolved from the profile defaults overlaid with any per-investigation // override supplied at preflight time. Nil means no prom target was @@ -300,6 +305,7 @@ type InvestigationDTO struct { SlackMCPEnabled bool `json:"slackMCPEnabled,omitempty"` IncidentioMCPEnabled bool `json:"incidentioMCPEnabled,omitempty"` LinkedRepos []repos.LinkedRepo `json:"linkedRepos,omitempty"` + Playbook string `json:"playbook,omitempty"` // CloudMCPs are the cloud-context MCP servers wired into this session, // derived from the profile's cloud sources. Empty when no cloud sources are // configured, or when the session carries no profile (e.g. an import). @@ -399,6 +405,7 @@ func (i *Investigation) Snapshot() InvestigationDTO { SlackMCPEnabled: i.SlackMCPEnabled, IncidentioMCPEnabled: i.IncidentioMCPEnabled, LinkedRepos: i.LinkedRepos, + Playbook: i.Playbook, CloudMCPs: cloudMCPsForProfile(i.Profile), CreatedAt: i.CreatedAt, Started: i.started, @@ -544,6 +551,7 @@ func (i *Investigation) Start() error { KubeconfigPath: i.KubeconfigPath, Cluster: i.ActiveContext, Profile: i.Profile, + Playbook: i.Playbook, }) if err != nil { i.mu.Unlock() diff --git a/internal/server/manager_test.go b/internal/server/manager_test.go index 28a42808..10c76cb6 100644 --- a/internal/server/manager_test.go +++ b/internal/server/manager_test.go @@ -758,3 +758,20 @@ func TestPersistOriginatingSignalRoundtrips(t *testing.T) { t.Fatalf("unexpected restore: %+v", got.OriginatingSignal) } } + +func TestLoadInvestigation_RestoresSelectedPlaybook(t *testing.T) { + dir := t.TempDir() + st := newStore(dir) + t.Cleanup(st.close) + require.NoError(t, st.writeMetadata(InvestigationDTO{ + ID: "rid", + SessionDir: dir, + CreatedAt: time.Now().UTC(), + Playbook: "release_verification", + })) + + loaded, err := loadInvestigation(dir) + require.NoError(t, err) + assert.Equal(t, "release_verification", loaded.Playbook) + assert.Equal(t, "release_verification", loaded.Snapshot().Playbook) +} diff --git a/internal/server/persist.go b/internal/server/persist.go index a4a4bfeb..adfc4f5d 100644 --- a/internal/server/persist.go +++ b/internal/server/persist.go @@ -44,6 +44,7 @@ type persistedMetadata struct { SlackChannelName string `json:"slackChannelName,omitempty"` Notes string `json:"notes,omitempty"` Label string `json:"label,omitempty"` + Playbook string `json:"playbook,omitempty"` MCPConfigPath string `json:"mcpConfigPath"` DocsPrefix string `json:"docsPrefix,omitempty"` SessionDir string `json:"sessionDir"` @@ -196,6 +197,7 @@ func (s *store) writeMetadata(dto InvestigationDTO) error { SlackChannelName: dto.SlackChannelName, Notes: dto.Notes, Label: dto.Label, + Playbook: dto.Playbook, MCPConfigPath: dto.MCPConfigPath, DocsPrefix: dto.DocsPrefix, SessionDir: dto.SessionDir, @@ -335,6 +337,7 @@ func loadInvestigation(dir string) (*Investigation, error) { SlackChannelName: meta.SlackChannelName, Notes: meta.Notes, Label: meta.Label, + Playbook: meta.Playbook, MCPConfigPath: meta.MCPConfigPath, DocsPrefix: meta.DocsPrefix, SessionDir: meta.SessionDir, diff --git a/internal/sessions/session.go b/internal/sessions/session.go index beb7cab4..672268c9 100644 --- a/internal/sessions/session.go +++ b/internal/sessions/session.go @@ -55,6 +55,33 @@ type Options struct { // preflight (when known). Rendered as kubernetes-context in the // opening prompt and exposed to namespace_derivation as "cluster". Cluster string + + // Playbook is the playbook id the operator selected at session + // start. When set, the session walks that playbook instead of the + // profile's guided investigation flow. Empty = profile defaults. + Playbook string +} + +// promptEnv projects the options into the prompt environment Build +// renders into the opening system prompt. Cluster feeds the +// Environment block's kubernetes-context line so the agent sees the +// context the launcher already seeded instead of "". +func (o Options) promptEnv() prompts.Env { + clusterID := clusterIDFromNamespace(o.Namespace) + return prompts.Env{ + Context: o.Cluster, + UserNotes: o.UserNotes, + InputValues: map[string]map[string]any{ + "cluster_id": {"value": clusterID}, + "incident_url": {"value": o.IncidentURL}, + "slack_channel": {"id": o.SlackChannelID, "name": o.SlackChannelName, "url": o.SlackChannelURL}, + "notes": {"value": o.UserNotes}, + }, + SlackMCPAvailable: o.SlackMCPAvailable, + IncidentioMCPAvailable: o.IncidentioMCPAvailable, + LinkedRepos: o.LinkedRepos, + Playbook: o.Playbook, + } } // Session is a thin wrapper around claude.Session with prompt + allowed-tools @@ -189,24 +216,9 @@ func (s *Session) Start(ctx context.Context) (<-chan claude.Event, error) { return s.inner.Start(ctx, s.startPrompt()) } -// startPrompt renders the opening prompt from Options. Cluster feeds the -// Environment block's kubernetes-context line so the agent sees the -// context the launcher already seeded instead of "". +// startPrompt renders the opening prompt from Options. func (s *Session) startPrompt() string { - clusterID := clusterIDFromNamespace(s.opts.Namespace) - return prompts.Build(prompts.Env{ - Context: s.opts.Cluster, - UserNotes: s.opts.UserNotes, - InputValues: map[string]map[string]any{ - "cluster_id": {"value": clusterID}, - "incident_url": {"value": s.opts.IncidentURL}, - "slack_channel": {"id": s.opts.SlackChannelID, "name": s.opts.SlackChannelName, "url": s.opts.SlackChannelURL}, - "notes": {"value": s.opts.UserNotes}, - }, - SlackMCPAvailable: s.opts.SlackMCPAvailable, - IncidentioMCPAvailable: s.opts.IncidentioMCPAvailable, - LinkedRepos: s.opts.LinkedRepos, - }, s.opts.Profile) + return prompts.Build(s.opts.promptEnv(), s.opts.Profile) } // Resume continues the most-recent conversation with a follow-up prompt. diff --git a/internal/sessions/session_test.go b/internal/sessions/session_test.go index 75a48deb..50f02c19 100644 --- a/internal/sessions/session_test.go +++ b/internal/sessions/session_test.go @@ -140,6 +140,13 @@ func TestNew_ForwardsProfileInvestigationModel(t *testing.T) { assert.Equal(t, "claude-opus-4-7", sess.inner.Model()) } +func TestPromptEnv_ForwardsSelectedPlaybook(t *testing.T) { + t.Parallel() + env := Options{Namespace: "abc-zeebe", Playbook: "release_verification"}.promptEnv() + assert.Equal(t, "release_verification", env.Playbook) + assert.Equal(t, "abc", env.InputValues["cluster_id"]["value"]) +} + func TestStartPrompt_EmitsSeededKubeContext(t *testing.T) { t.Parallel() s := &Session{opts: Options{ diff --git a/prompts/prompts.go b/prompts/prompts.go index 5e3157c0..cc88d012 100644 --- a/prompts/prompts.go +++ b/prompts/prompts.go @@ -38,6 +38,12 @@ type Env struct { // known-noop outcomes so the ingestion agent can dismiss similar // signals on the next poll. OriginatingSignalSet bool + // Playbook, when non-empty, is the playbook id the operator picked + // at session start. It replaces the profile's entrypoint playbook + // and suppresses the closing playbook: the session walks only the + // selected playbook, without the guided investigation flow around + // it. Empty means the profile defaults apply. + Playbook string } // incidentioRefFromURL extracts the trailing path segment of an @@ -105,9 +111,13 @@ func Build(env Env, prof *profile.Profile) string { b.WriteString("kubernetes-context: ") b.WriteString(orUnset(env.Context)) b.WriteString("\nsuggested-entrypoint-playbook: ") - b.WriteString(prof.Playbooks.Entrypoint) - b.WriteString("\nsuggested-closing-playbook: ") - b.WriteString(prof.Playbooks.Closing) + if env.Playbook != "" { + b.WriteString(env.Playbook) + } else { + b.WriteString(prof.Playbooks.Entrypoint) + b.WriteString("\nsuggested-closing-playbook: ") + b.WriteString(prof.Playbooks.Closing) + } b.WriteString("\n") // Dynamic section: walk profile inputs in declaration order. @@ -148,7 +158,11 @@ func Build(env Env, prof *profile.Profile) string { b.WriteString("Write the wiki entry with `status: wontfix` and include enough ") b.WriteString("symptom keywords for `wiki_correlate` to find it.\n") } - b.WriteString("- Investigation playbooks: mcp__triagent-strategies__* (start with `walk_playbook` against the `suggested-entrypoint-playbook` from the parameter block. Walk `suggested-closing-playbook` after every `summarize`.)") + if env.Playbook != "" { + b.WriteString("- Investigation playbooks: mcp__triagent-strategies__* (the operator selected this playbook for the session. Start with `walk_playbook` against the `suggested-entrypoint-playbook` from the parameter block and follow it to completion. There is no closing playbook to walk afterwards.)") + } else { + b.WriteString("- Investigation playbooks: mcp__triagent-strategies__* (start with `walk_playbook` against the `suggested-entrypoint-playbook` from the parameter block. Walk `suggested-closing-playbook` after every `summarize`.)") + } b.WriteString("\n- Cluster-inspection tools: mcp__triagent-k8s__* (read-only: list_resource_kinds, list_resources, get_resource, get_logs, list_events, list_namespaces, trace_crossplane). Pass `namespace` on every call. Default to `cluster-resource-namespace` from the parameter block. If it is ``, call `list_namespaces`.") if env.IncidentioMCPAvailable { b.WriteString("\n- incident.io tools: mcp__triagent-incidentio__* (incidentio_get_incident, incidentio_get_timeline, incidentio_get_postmortem, incidentio_search_related). Pass `incident_id` on every call.") diff --git a/prompts/prompts_test.go b/prompts/prompts_test.go index 8b384dbd..ea85d69d 100644 --- a/prompts/prompts_test.go +++ b/prompts/prompts_test.go @@ -366,6 +366,27 @@ func TestBuildIncludesAutoTriggerHintWhenSet(t *testing.T) { } } +func TestBuild_SelectedPlaybookReplacesEntrypointAndDropsClosing(t *testing.T) { + t.Parallel() + out := Build(Env{Context: "ctx-A", Playbook: "release_verification"}, testProf()) + + assert.Contains(t, out, "suggested-entrypoint-playbook: release_verification") + assert.NotContains(t, out, "suggested-entrypoint-playbook: investigation") + assert.NotContains(t, out, "suggested-closing-playbook") + assert.NotContains(t, out, "capture_offer") + assert.Contains(t, out, "operator selected this playbook", + "the tool bullet must tell the agent the playbook was chosen by the operator and no closing playbook follows") +} + +func TestBuild_NoSelectedPlaybookKeepsProfileDefaults(t *testing.T) { + t.Parallel() + out := Build(Env{Context: "ctx-A"}, testProf()) + + assert.Contains(t, out, "suggested-entrypoint-playbook: investigation") + assert.Contains(t, out, "suggested-closing-playbook: capture_offer") + assert.NotContains(t, out, "operator selected this playbook") +} + // Every session the launcher spawns writes prose a human reads later // (summaries, wiki entries, playbook YAML). The writing-simply skill // rides in the system prompt rather than relying on skill discovery,
+ playbook: {investigation.playbook} +