diff --git a/AGENT_VERSION b/AGENT_VERSION
index c029dfc187..33c90229b3 100644
--- a/AGENT_VERSION
+++ b/AGENT_VERSION
@@ -1 +1 @@
-v0.6.67
\ No newline at end of file
+v0.6.68
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index f33905c8c4..495e90dc88 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -91,7 +91,7 @@ RUN mix do db.certs, agent.chart, sentry.package_source_code, release
FROM alpine:3.21.3 as tools
ARG TARGETARCH=amd64
-ENV CLI_VERSION=v0.12.65
+ENV CLI_VERSION=v0.12.66
COPY AGENT_VERSION AGENT_VERSION
diff --git a/charts/ai-proxy/values.yaml b/charts/ai-proxy/values.yaml
index 16fbb4eb96..bfa8937941 100644
--- a/charts/ai-proxy/values.yaml
+++ b/charts/ai-proxy/values.yaml
@@ -26,6 +26,7 @@ config:
# OpenAI model prefixes routed to Bedrock Mantle when authentication is configured.
bedrockMantleModelPrefixes:
- gpt-5.6
+ - gpt-6
secrets:
token: ~
diff --git a/go/ai-proxy/args/args.go b/go/ai-proxy/args/args.go
index 8e5bbab7fa..40827c4699 100644
--- a/go/ai-proxy/args/args.go
+++ b/go/ai-proxy/args/args.go
@@ -37,7 +37,7 @@ var (
argsProviderAWSRegion = pflag.String("provider-aws-region", helpers.GetPluralEnv(envProviderAWSRegion, ""), "Provider AWS region used to connect to BedRock API.")
argBedrockMantleKey = pflag.String("bedrock-mantle-key", helpers.GetPluralEnv(envBedrockMantleKey, ""), "Amazon Bedrock Mantle API key. Can be overridden via PLRL_BEDROCK_MANTLE_KEY env var.")
argBedrockMantleAWSRegion = pflag.String("bedrock-mantle-aws-region", helpers.GetPluralEnv(envBedrockMantleAWSRegion, defaultBedrockMantleAWSRegion), "AWS region for Amazon Bedrock Mantle. Defaults to us-east-1.")
- argBedrockMantlePrefixes = pflag.StringSlice("bedrock-mantle-model-prefixes", helpers.GetPluralEnvSlice(envBedrockMantlePrefixes, []string{"gpt-5.6"}), "OpenAI model prefixes routed to Amazon Bedrock Mantle.")
+ argBedrockMantlePrefixes = pflag.StringSlice("bedrock-mantle-model-prefixes", helpers.GetPluralEnvSlice(envBedrockMantlePrefixes, []string{"gpt-5.6", "gpt-6"}), "OpenAI model prefixes routed to Amazon Bedrock Mantle.")
argMantleSigV4 = pflag.Bool("mantle-sigv4", helpers.GetPluralEnvBool(envMantleSigV4, false), "Use AWS SigV4 authentication from the default credentials chain for Amazon Bedrock Mantle. Can be overridden via PLRL_MANTLE_SIGV4 env var.")
argPort = pflag.Int("port", defaultPort, "The port to listen on. Defaults to port 8000.")
argAddress = pflag.IP("address", net.ParseIP(defaultAddress), "The IP address to serve on. Defaults to 0.0.0.0 (all interfaces).")
diff --git a/go/ai-proxy/proxy/openai/mantle_test.go b/go/ai-proxy/proxy/openai/mantle_test.go
index c27739cfd8..71457a7d97 100644
--- a/go/ai-proxy/proxy/openai/mantle_test.go
+++ b/go/ai-proxy/proxy/openai/mantle_test.go
@@ -387,6 +387,29 @@ func TestRouteToMantle(t *testing.T) {
}
}
+func TestRouteToMantleRoutesPrefixedGPT6ModelWithoutRewritingIt(t *testing.T) {
+ requestBody := `{"model":"openai.gpt-6-terra","input":"test"}`
+ request := &http.Request{
+ Body: io.NopCloser(bytes.NewBufferString(requestBody)),
+ }
+ proxyRequest := &httputil.ProxyRequest{
+ In: request,
+ Out: request.Clone(request.Context()),
+ }
+
+ if !routeToMantle(proxyRequest, []string{"gpt-5.6", "gpt-6"}) {
+ t.Fatal("expected GPT-6 request to route to Mantle")
+ }
+
+ body, err := io.ReadAll(proxyRequest.Out.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := string(body); got != requestBody {
+ t.Errorf("body: got %q, want %q", got, requestBody)
+ }
+}
+
func TestRouteToMantleLeavesOtherModelsUnchanged(t *testing.T) {
request := &http.Request{
Body: io.NopCloser(bytes.NewBufferString(`{"model":"gpt-4.1","input":"test"}`)),
diff --git a/js/console/src/components/ai/chatbot/toolCallIcons.tsx b/js/console/src/components/ai/chatbot/toolCallIcons.tsx
index 9d28e969a6..6c9d37b32e 100644
--- a/js/console/src/components/ai/chatbot/toolCallIcons.tsx
+++ b/js/console/src/components/ai/chatbot/toolCallIcons.tsx
@@ -1,5 +1,11 @@
-import { IconProps, PythonLogoIcon, ToolIcon } from '@pluralsh/design-system'
-import { ToolCallKind } from './toolCallDisplay'
+import {
+ IconProps,
+ McpLogoIcon,
+ PythonLogoIcon,
+ TerminalIcon,
+ ToolIcon,
+} from '@pluralsh/design-system'
+import { isCmdToolKind, ToolCallKind } from './toolCallDisplay'
/** Icon next to a tool name when it is not a configured workbench tool. */
export function ToolCallKindIcon({
@@ -18,11 +24,17 @@ export function ToolCallKindIcon({
)
}
+ const Icon = isCmdToolKind(kind)
+ ? TerminalIcon
+ : kind === 'mcp_tool_call'
+ ? McpLogoIcon
+ : ToolIcon
+
return (
-
)
diff --git a/js/console/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx b/js/console/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx
index d69230c3ac..e2f85bedc3 100644
--- a/js/console/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx
+++ b/js/console/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx
@@ -16,6 +16,11 @@ import { useTheme } from 'styled-components'
import { StackedText } from 'components/utils/table/StackedText'
import { CaptionP } from 'components/utils/typography/Text'
+import {
+ useDeleteWorkbenchKnowledgeMutation,
+ useUpdateWorkbenchKnowledgeMutation,
+ WorkbenchKnowledgeAttributes,
+} from 'generated/graphql'
import { fromNow } from 'utils/datetime'
import { isNonNullable } from 'utils/isNonNullable'
@@ -37,6 +42,18 @@ const DUPLICATE_KNOWLEDGE_NAME_ERROR =
const normalizeKnowledgeName = (name: Nullable) =>
(name ?? '').trim().toLowerCase()
+const toErrorMessage = (error: unknown) =>
+ error instanceof Error ? error.message : String(error)
+
+const knowledgeToAttributes = (
+ entry: WorkbenchFormKnowledge
+): WorkbenchKnowledgeAttributes => ({
+ name: entry.name,
+ description: entry.description ?? null,
+ knowledge: entry.knowledge,
+ labels: entry.labels,
+})
+
const validateKnowledgeName = ({
draftName,
existingNames,
@@ -79,6 +96,10 @@ export function KnowledgeSubStep({
const theme = useTheme()
const update = createFormUpdater(setFormState)
const entries = formState.workbenchKnowledge
+ const [updateKnowledge, { loading: updateLoading }] =
+ useUpdateWorkbenchKnowledgeMutation()
+ const [deleteKnowledge] = useDeleteWorkbenchKnowledgeMutation()
+ const [mutationError, setMutationError] = useState>(null)
const existingNames = useMemo(
() => entries.map((entry) => entry.name),
[entries]
@@ -95,14 +116,23 @@ export function KnowledgeSubStep({
const handleEdit = (id: string) => setEditingId(id)
- const handleDelete = (id: string) =>
- update((d) => {
- d.workbenchKnowledge = (d.workbenchKnowledge ?? []).filter(
- (entry) => entry.id !== id
- )
- })
+ const handleDelete = async (id: string) => {
+ setMutationError(null)
+ try {
+ await deleteKnowledge({ variables: { id } })
+ update((d) => {
+ d.workbenchKnowledge = (d.workbenchKnowledge ?? []).filter(
+ (entry) => entry.id !== id
+ )
+ })
+ } catch (error) {
+ setMutationError(toErrorMessage(error))
+ }
+ }
- const handleSave = (draft: WorkbenchFormKnowledge): Nullable => {
+ const handleSave = async (
+ draft: WorkbenchFormKnowledge
+ ): Promise> => {
const canSave = !!draft.knowledge.trim() && !!draft.name.trim()
if (!canSave) return 'Knowledge name and contents are required.'
@@ -123,12 +153,22 @@ export function KnowledgeSubStep({
.map((label) => label.trim())
.filter(Boolean),
}
- update((d) => {
- const list = [...(d.workbenchKnowledge ?? [])]
- const idx = list.findIndex((entry) => entry.id === draft.id)
- if (idx >= 0) list[idx] = normalizedDraft
- d.workbenchKnowledge = list
- })
+ try {
+ await updateKnowledge({
+ variables: {
+ id: normalizedDraft.id,
+ attributes: knowledgeToAttributes(normalizedDraft),
+ },
+ })
+ update((d) => {
+ const list = [...(d.workbenchKnowledge ?? [])]
+ const idx = list.findIndex((entry) => entry.id === draft.id)
+ if (idx >= 0) list[idx] = normalizedDraft
+ d.workbenchKnowledge = list
+ })
+ } catch (error) {
+ return toErrorMessage(error)
+ }
setEditingId(null)
return null
}
@@ -142,6 +182,7 @@ export function KnowledgeSubStep({
existingNames={existingNames}
onSave={handleSave}
onCancel={handleCancel}
+ loading={updateLoading}
/>
)
}
@@ -181,11 +222,14 @@ export function KnowledgeSubStep({
entry={entry}
isLast={idx === entries.length - 1}
onEdit={() => handleEdit(entry.id)}
- onDelete={() => handleDelete(entry.id)}
+ onDelete={() => void handleDelete(entry.id)}
/>
))}
)}
+ {!!mutationError && (
+ {mutationError}
+ )}
)
@@ -250,11 +294,13 @@ function KnowledgeForm({
existingNames,
onSave,
onCancel,
+ loading,
}: {
initialEntry: WorkbenchFormKnowledge
existingNames: Nullable[]
- onSave: (entry: WorkbenchFormKnowledge) => Nullable
+ onSave: (entry: WorkbenchFormKnowledge) => Promise>
onCancel: () => void
+ loading: boolean
}) {
const [draft, setDraft] = useState(initialEntry)
const [labelDraft, setLabelDraft] = useState('')
@@ -305,11 +351,12 @@ function KnowledgeForm({
) : (
@@ -318,7 +365,7 @@ function KnowledgeForm({
)
return () => setFooterActions(null)
- }, [canContinue, canSave, currentStep, setFooterActions])
+ }, [canContinue, canSave, currentStep, loading, setFooterActions])
useEffect(() => {
setRightContent(
diff --git a/js/console/src/components/workbenches/workbench/create-edit/PluralSkillsSubStep.tsx b/js/console/src/components/workbenches/workbench/create-edit/PluralSkillsSubStep.tsx
index c641942605..9c5a3ad825 100644
--- a/js/console/src/components/workbenches/workbench/create-edit/PluralSkillsSubStep.tsx
+++ b/js/console/src/components/workbenches/workbench/create-edit/PluralSkillsSubStep.tsx
@@ -21,6 +21,9 @@ import { StackedText } from 'components/utils/table/StackedText'
import { InlineLink } from 'components/utils/typography/InlineLink'
import { CaptionP } from 'components/utils/typography/Text'
import {
+ useCreateWorkbenchSkillMutation,
+ useDeleteWorkbenchSkillMutation,
+ useUpdateWorkbenchSkillMutation,
WorkbenchSkillAttributes,
WorkbenchSkillSubagent,
} from 'generated/graphql'
@@ -30,6 +33,8 @@ import { createFormUpdater, WorkbenchFormStepProps } from './WorkbenchFormSteps'
import {
useWorkbenchFormCardRightContent,
useWorkbenchFormFooterActions,
+ useWorkbenchFormPersistence,
+ WorkbenchFormSkill,
} from './WorkbenchCreateOrEdit'
export const CREATE_MODE_NAME = ''
@@ -53,6 +58,9 @@ const subagentLabel = (subagent: WorkbenchSkillSubagent) =>
const normalizeSkillName = (name: Nullable) =>
(name ?? '').trim().toLowerCase()
+const toErrorMessage = (error: unknown) =>
+ error instanceof Error ? error.message : String(error)
+
const validateSkillName = ({
draftName,
existingSkillNames,
@@ -83,9 +91,18 @@ export function PluralSkillsSubStep({
}: WorkbenchFormStepProps) {
const theme = useTheme()
const update = createFormUpdater(setFormState)
- const skills: WorkbenchSkillAttributes[] = (
- formState.workbenchSkills ?? []
- ).filter(isNonNullable)
+ const { isCreateMode, workbenchId } = useWorkbenchFormPersistence()
+ const skills: WorkbenchFormSkill[] = (formState.workbenchSkills ?? []).filter(
+ isNonNullable
+ )
+ const [createSkill, { loading: createLoading }] =
+ useCreateWorkbenchSkillMutation()
+ const [updateSkill, { loading: updateLoading }] =
+ useUpdateWorkbenchSkillMutation()
+ const [deleteSkill, { loading: deleteLoading }] =
+ useDeleteWorkbenchSkillMutation()
+ const [mutationError, setMutationError] = useState>(null)
+ const mutationLoading = createLoading || updateLoading || deleteLoading
const existingSkillNames = useMemo(
() => skills.map((skill) => skill.name),
[skills]
@@ -104,16 +121,31 @@ export function PluralSkillsSubStep({
const handleEdit = (name: string) => setEditingName(name)
- const handleDelete = (name: string) =>
+ const removeSkill = (name: string) =>
update((d) => {
- const next: WorkbenchSkillAttributes[] = (d.workbenchSkills ?? [])
- .filter(isNonNullable)
- .filter((s) => s.name !== name)
- d.workbenchSkills =
- next as WorkbenchFormStepProps['formState']['workbenchSkills']
+ d.workbenchSkills = (d.workbenchSkills ?? []).filter(
+ (skill) => skill?.name !== name
+ )
})
- const handleSave = (draft: WorkbenchSkillAttributes): Nullable => {
+ const handleDelete = async (skill: WorkbenchFormSkill) => {
+ setMutationError(null)
+ if (isCreateMode || !skill.id) {
+ removeSkill(skill.name)
+ return
+ }
+
+ try {
+ await deleteSkill({ variables: { id: skill.id } })
+ removeSkill(skill.name)
+ } catch (error) {
+ setMutationError(toErrorMessage(error))
+ }
+ }
+
+ const handleSave = async (
+ draft: WorkbenchSkillAttributes
+ ): Promise> => {
const canSave = !!draft.contents.trim() && !!draft.name.trim()
if (!canSave) return 'Skill name and contents are required.'
@@ -132,18 +164,43 @@ export function PluralSkillsSubStep({
(draft.subagents?.filter(isNonNullable) as WorkbenchSkillSubagent[]) ??
[],
}
- update((d) => {
- const list: WorkbenchSkillAttributes[] = (d.workbenchSkills ?? []).filter(
- isNonNullable
- )
- const idx = editingName
- ? list.findIndex((s) => s.name === editingName)
- : -1
- if (idx >= 0) list[idx] = normalizedDraft
- else list.push(normalizedDraft)
- d.workbenchSkills =
- list as WorkbenchFormStepProps['formState']['workbenchSkills']
- })
+ const saveToForm = (saved: WorkbenchFormSkill) =>
+ update((d) => {
+ const list: WorkbenchFormSkill[] = (d.workbenchSkills ?? []).filter(
+ isNonNullable
+ )
+ const idx = editingName
+ ? list.findIndex((s) => s.name === editingName)
+ : -1
+ if (idx >= 0) list[idx] = saved
+ else list.push(saved)
+ d.workbenchSkills = list
+ })
+
+ if (isCreateMode || !workbenchId) {
+ saveToForm(normalizedDraft)
+ setEditingName(null)
+ return null
+ }
+
+ try {
+ const existingId = editingSkill?.id
+ if (existingId) {
+ await updateSkill({
+ variables: { id: existingId, attributes: normalizedDraft },
+ })
+ saveToForm({ ...normalizedDraft, id: existingId })
+ } else {
+ const result = await createSkill({
+ variables: { workbenchId, attributes: normalizedDraft },
+ })
+ const id = result.data?.createWorkbenchSkill?.id
+ if (!id) return 'The skill was not created.'
+ saveToForm({ ...normalizedDraft, id })
+ }
+ } catch (error) {
+ return toErrorMessage(error)
+ }
setEditingName(null)
return null
}
@@ -158,6 +215,7 @@ export function PluralSkillsSubStep({
isNew={editingName === CREATE_MODE_NAME}
onSave={handleSave}
onCancel={handleCancel}
+ loading={mutationLoading}
/>
)
}
@@ -217,11 +275,14 @@ export function PluralSkillsSubStep({
skill={skill}
isLast={idx === skills.length - 1}
onEdit={() => handleEdit(skill.name)}
- onDelete={() => handleDelete(skill.name)}
+ onDelete={() => void handleDelete(skill)}
/>
))}
)}
+ {!!mutationError && (
+ {mutationError}
+ )}
)
}
@@ -286,12 +347,14 @@ function PluralSkillForm({
isNew,
onSave,
onCancel,
+ loading,
}: {
initialSkill: Nullable
existingSkillNames: Nullable[]
isNew: boolean
- onSave: (skill: WorkbenchSkillAttributes) => Nullable
+ onSave: (skill: WorkbenchSkillAttributes) => Promise>
onCancel: () => void
+ loading: boolean
}) {
const [draft, setDraft] = useState(
() =>
@@ -356,11 +419,12 @@ function PluralSkillForm({
) : (
@@ -369,7 +433,7 @@ function PluralSkillForm({
)
return () => setFooterActions(null)
- }, [canContinue, canSave, currentStep, isNew, setFooterActions])
+ }, [canContinue, canSave, currentStep, isNew, loading, setFooterActions])
useEffect(() => {
setRightContent(
diff --git a/js/console/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx b/js/console/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx
index 7822db9bbb..0ab29cb26a 100644
--- a/js/console/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx
+++ b/js/console/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx
@@ -16,13 +16,10 @@ import { getWorkbenchesBreadcrumbs } from 'components/workbenches/Workbenches'
import {
PolicyBindingFragment,
useCreateWorkbenchMutation,
- useDeleteWorkbenchKnowledgeMutation,
- useUpdateWorkbenchKnowledgeMutation,
useUpdateWorkbenchMutation,
useWorkbenchQuery,
WorkbenchAttributes,
WorkbenchFragment,
- WorkbenchKnowledgeAttributes,
WorkbenchSkillAttributes,
WorkbenchSkillSubagent,
} from 'generated/graphql'
@@ -50,6 +47,8 @@ type WorkbenchFormContextValue = {
setTabs: (tabs: ReactNode | null) => void
setFooterActions: (actions: ReactNode | null) => void
setRightContent: (content: ReactNode | null) => void
+ workbenchId: Nullable
+ isCreateMode: boolean
}
const WorkbenchFormContext = createContext(
null
@@ -82,8 +81,24 @@ export function useWorkbenchFormCardRightContent() {
return { setRightContent: ctx.setRightContent }
}
+export function useWorkbenchFormPersistence() {
+ const ctx = useContext(WorkbenchFormContext)
+ if (!ctx)
+ throw new Error(
+ 'useWorkbenchFormPersistence must be used inside a WorkbenchForm'
+ )
+ return {
+ workbenchId: ctx.workbenchId,
+ isCreateMode: ctx.isCreateMode,
+ }
+}
+
// requires every key from WorkbenchAttributes to be present. readBindings/writeBindings
// use FormBinding[] so BindingInput can show chips (user email / group name).
+export type WorkbenchFormSkill = WorkbenchSkillAttributes & {
+ id?: string
+}
+
export type WorkbenchFormKnowledge = {
id: string
name: string
@@ -96,11 +111,15 @@ export type WorkbenchFormKnowledge = {
export type WorkbenchFormState = Omit<
Required,
- 'readBindings' | 'writeBindings' | 'projectId' | 'systemPrompt'
+ | 'readBindings'
+ | 'writeBindings'
+ | 'projectId'
+ | 'systemPrompt'
+ | 'workbenchSkills'
> & {
readBindings: PolicyBindingFragment[]
writeBindings: PolicyBindingFragment[]
- workbenchSkills: WorkbenchSkillAttributes[]
+ workbenchSkills: WorkbenchFormSkill[]
workbenchKnowledge: WorkbenchFormKnowledge[]
}
@@ -196,8 +215,14 @@ function WorkbenchForm({
const [footerActions, setFooterActions] = useState(null)
const [rightContent, setRightContent] = useState(null)
const formContextValue = useMemo(
- () => ({ setTabs: setCardTabs, setFooterActions, setRightContent }),
- []
+ () => ({
+ setTabs: setCardTabs,
+ setFooterActions,
+ setRightContent,
+ workbenchId,
+ isCreateMode,
+ }),
+ [isCreateMode, workbenchId]
)
const [stepStatuses, setStepStatuses] = useState<
Record
@@ -239,63 +264,15 @@ function WorkbenchForm({
refetchQueries: ['Workbenches'],
awaitRefetchQueries: true,
})
- const [updateKnowledge, { loading: updateKnowledgeLoading }] =
- useUpdateWorkbenchKnowledgeMutation()
- const [deleteKnowledge, { loading: deleteKnowledgeLoading }] =
- useDeleteWorkbenchKnowledgeMutation()
- const [knowledgeError, setKnowledgeError] = useState(null)
- const mutationLoading =
- createLoading ||
- updateLoading ||
- updateKnowledgeLoading ||
- deleteKnowledgeLoading
- const mutationError = createError || updateError || knowledgeError
-
- const persistKnowledgeChanges = async () => {
- const initialById = new Map(
- initialFormState.workbenchKnowledge.map((entry) => [entry.id, entry])
- )
- const currentIds = new Set(
- formState.workbenchKnowledge.map((entry) => entry.id)
- )
- const deletions = [...initialById.keys()].filter(
- (id) => !currentIds.has(id)
- )
- const updates = formState.workbenchKnowledge.filter((entry) => {
- const initial = initialById.get(entry.id)
- return (
- !!initial && knowledgeSignature(entry) !== knowledgeSignature(initial)
- )
- })
-
- await Promise.all([
- ...deletions.map((id) => deleteKnowledge({ variables: { id } })),
- ...updates.map((entry) =>
- updateKnowledge({
- variables: {
- id: entry.id,
- attributes: knowledgeToAttributes(entry),
- },
- })
- ),
- ])
- }
+ const mutationLoading = createLoading || updateLoading
+ const mutationError = createError || updateError
- const onSave = async () => {
- const attributes = formStateToAttributes(formState)
+ const onSave = () => {
+ const attributes = formStateToAttributes(formState, isCreateMode)
if (isCreateMode) {
createWorkbench({ variables: { attributes } })
return
}
- try {
- setKnowledgeError(null)
- await persistKnowledgeChanges()
- } catch (error) {
- setKnowledgeError(
- error instanceof Error ? error : new Error(String(error))
- )
- return
- }
updateWorkbench({
variables: { id: workbenchId ?? '', attributes },
})
@@ -506,22 +483,10 @@ const validateForm = (formState: WorkbenchFormState) =>
validateStep(label as WorkbenchStepLabel, formState)
)
-function knowledgeToAttributes(
- entry: WorkbenchFormKnowledge
-): WorkbenchKnowledgeAttributes {
- return {
- name: entry.name,
- description: entry.description ?? null,
- knowledge: entry.knowledge,
- labels: entry.labels,
- }
-}
-
-function knowledgeSignature(entry: WorkbenchFormKnowledge) {
- return JSON.stringify(knowledgeToAttributes(entry))
-}
-
-function formStateToAttributes(state: WorkbenchFormState): WorkbenchAttributes {
+function formStateToAttributes(
+ state: WorkbenchFormState,
+ includeWorkbenchSkills: boolean
+): WorkbenchAttributes {
const {
name,
readBindings,
@@ -535,8 +500,11 @@ function formStateToAttributes(state: WorkbenchFormState): WorkbenchAttributes {
return {
...deepOmitFalsy(rest),
name: name ?? '',
- // Keep explicit empty list so "delete all skills" is persisted.
- workbenchSkills: (workbenchSkills ?? []).filter(isNonNullable),
+ ...(includeWorkbenchSkills && {
+ workbenchSkills: (workbenchSkills ?? [])
+ .filter(isNonNullable)
+ .map(({ id: _id, ...skill }) => skill),
+ }),
// Keep explicit mode values so disabling an existing default persists.
...(modes ? { modes: modesToAttributes(modes) } : {}),
...(readBindings?.length && {
@@ -601,6 +569,7 @@ function sanitizeInitialForm({
.map((edge) => edge?.node)
.filter(isNonNullable)
.map((skill) => ({
+ id: skill.id,
name: skill.name ?? '',
description: skill.description ?? null,
contents: skill.contents ?? '',
diff --git a/js/console/src/generated/graphql.ts b/js/console/src/generated/graphql.ts
index c7ec6c78a2..48d0016d3a 100644
--- a/js/console/src/generated/graphql.ts
+++ b/js/console/src/generated/graphql.ts
@@ -23404,6 +23404,29 @@ export type UpdateWorkbenchMutationVariables = Exact<{
export type UpdateWorkbenchMutation = { __typename?: 'RootMutationType', updateWorkbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', selfService?: boolean | null, infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null, review?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, drain?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, workbenchKnowledge?: { __typename?: 'WorkbenchKnowledgeConnection', edges?: Array<{ __typename?: 'WorkbenchKnowledgeEdge', node?: { __typename?: 'WorkbenchKnowledge', id: string, name?: string | null, description?: string | null, knowledge?: string | null, labels?: Array | null, usages?: number | null, lastUsedAt?: string | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, oauth?: { __typename?: 'OauthTokenExchange', enabled?: boolean | null, type?: OauthTokenExchangeType | null, tokenUrl?: string | null, clientId?: string | null, keyId?: string | null, audience?: string | null, resource?: string | null, scopes?: Array | null } | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, victoriaLogs?: { __typename?: 'WorkbenchToolVictoriaLogsConnection', url?: string | null, username?: string | null, accountId?: string | null, projectId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, jira?: { __typename?: 'WorkbenchToolJiraConnection', email: string, url: string } | null, jiraDatacenter?: { __typename?: 'WorkbenchToolJiraDatacenterConnection', url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, tokenType?: SplunkTokenType | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null };
+export type CreateWorkbenchSkillMutationVariables = Exact<{
+ workbenchId: Scalars['ID']['input'];
+ attributes: WorkbenchSkillAttributes;
+}>;
+
+
+export type CreateWorkbenchSkillMutation = { __typename?: 'RootMutationType', createWorkbenchSkill?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null };
+
+export type UpdateWorkbenchSkillMutationVariables = Exact<{
+ id: Scalars['ID']['input'];
+ attributes: WorkbenchSkillAttributes;
+}>;
+
+
+export type UpdateWorkbenchSkillMutation = { __typename?: 'RootMutationType', updateWorkbenchSkill?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null };
+
+export type DeleteWorkbenchSkillMutationVariables = Exact<{
+ id: Scalars['ID']['input'];
+}>;
+
+
+export type DeleteWorkbenchSkillMutation = { __typename?: 'RootMutationType', deleteWorkbenchSkill?: { __typename?: 'WorkbenchSkill', id: string } | null };
+
export type UpdateWorkbenchKnowledgeMutationVariables = Exact<{
id: Scalars['ID']['input'];
attributes: WorkbenchKnowledgeAttributes;
@@ -49370,6 +49393,115 @@ export function useUpdateWorkbenchMutation(baseOptions?: Apollo.MutationHookOpti
export type UpdateWorkbenchMutationHookResult = ReturnType;
export type UpdateWorkbenchMutationResult = Apollo.MutationResult;
export type UpdateWorkbenchMutationOptions = Apollo.BaseMutationOptions;
+export const CreateWorkbenchSkillDocument = gql`
+ mutation CreateWorkbenchSkill($workbenchId: ID!, $attributes: WorkbenchSkillAttributes!) {
+ createWorkbenchSkill(workbenchId: $workbenchId, attributes: $attributes) {
+ id
+ name
+ description
+ contents
+ subagents
+ }
+}
+ `;
+export type CreateWorkbenchSkillMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useCreateWorkbenchSkillMutation__
+ *
+ * To run a mutation, you first call `useCreateWorkbenchSkillMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useCreateWorkbenchSkillMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [createWorkbenchSkillMutation, { data, loading, error }] = useCreateWorkbenchSkillMutation({
+ * variables: {
+ * workbenchId: // value for 'workbenchId'
+ * attributes: // value for 'attributes'
+ * },
+ * });
+ */
+export function useCreateWorkbenchSkillMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(CreateWorkbenchSkillDocument, options);
+ }
+export type CreateWorkbenchSkillMutationHookResult = ReturnType;
+export type CreateWorkbenchSkillMutationResult = Apollo.MutationResult;
+export type CreateWorkbenchSkillMutationOptions = Apollo.BaseMutationOptions;
+export const UpdateWorkbenchSkillDocument = gql`
+ mutation UpdateWorkbenchSkill($id: ID!, $attributes: WorkbenchSkillAttributes!) {
+ updateWorkbenchSkill(id: $id, attributes: $attributes) {
+ id
+ name
+ description
+ contents
+ subagents
+ }
+}
+ `;
+export type UpdateWorkbenchSkillMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useUpdateWorkbenchSkillMutation__
+ *
+ * To run a mutation, you first call `useUpdateWorkbenchSkillMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateWorkbenchSkillMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [updateWorkbenchSkillMutation, { data, loading, error }] = useUpdateWorkbenchSkillMutation({
+ * variables: {
+ * id: // value for 'id'
+ * attributes: // value for 'attributes'
+ * },
+ * });
+ */
+export function useUpdateWorkbenchSkillMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(UpdateWorkbenchSkillDocument, options);
+ }
+export type UpdateWorkbenchSkillMutationHookResult = ReturnType;
+export type UpdateWorkbenchSkillMutationResult = Apollo.MutationResult;
+export type UpdateWorkbenchSkillMutationOptions = Apollo.BaseMutationOptions;
+export const DeleteWorkbenchSkillDocument = gql`
+ mutation DeleteWorkbenchSkill($id: ID!) {
+ deleteWorkbenchSkill(id: $id) {
+ id
+ }
+}
+ `;
+export type DeleteWorkbenchSkillMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useDeleteWorkbenchSkillMutation__
+ *
+ * To run a mutation, you first call `useDeleteWorkbenchSkillMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useDeleteWorkbenchSkillMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [deleteWorkbenchSkillMutation, { data, loading, error }] = useDeleteWorkbenchSkillMutation({
+ * variables: {
+ * id: // value for 'id'
+ * },
+ * });
+ */
+export function useDeleteWorkbenchSkillMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(DeleteWorkbenchSkillDocument, options);
+ }
+export type DeleteWorkbenchSkillMutationHookResult = ReturnType;
+export type DeleteWorkbenchSkillMutationResult = Apollo.MutationResult;
+export type DeleteWorkbenchSkillMutationOptions = Apollo.BaseMutationOptions;
export const UpdateWorkbenchKnowledgeDocument = gql`
mutation UpdateWorkbenchKnowledge($id: ID!, $attributes: WorkbenchKnowledgeAttributes!) {
updateWorkbenchKnowledge(id: $id, attributes: $attributes) {
@@ -51786,6 +51918,9 @@ export const namedOperations = {
RejectWorkbenchJobActivity: 'RejectWorkbenchJobActivity',
CreateWorkbench: 'CreateWorkbench',
UpdateWorkbench: 'UpdateWorkbench',
+ CreateWorkbenchSkill: 'CreateWorkbenchSkill',
+ UpdateWorkbenchSkill: 'UpdateWorkbenchSkill',
+ DeleteWorkbenchSkill: 'DeleteWorkbenchSkill',
UpdateWorkbenchKnowledge: 'UpdateWorkbenchKnowledge',
DeleteWorkbenchKnowledge: 'DeleteWorkbenchKnowledge',
CreateWorkbenchEval: 'CreateWorkbenchEval',
diff --git a/js/console/src/generated/persisted-queries/client.json b/js/console/src/generated/persisted-queries/client.json
index 42ccee8b96..a27b40630c 100644
--- a/js/console/src/generated/persisted-queries/client.json
+++ b/js/console/src/generated/persisted-queries/client.json
@@ -2182,6 +2182,21 @@
"name": "UpdateWorkbench",
"body": "mutation UpdateWorkbench($id: ID!, $attributes: WorkbenchAttributes!) {\n updateWorkbench(id: $id, attributes: $attributes) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n selfService\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n workbenchKnowledge(first: 50) {\n edges {\n node {\n id\n name\n description\n knowledge\n labels\n usages\n lastUsedAt\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n review\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n drain\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n oauth {\n enabled\n type\n tokenUrl\n clientId\n keyId\n audience\n resource\n scopes\n __typename\n }\n configuration {\n ...WorkbenchToolConfigurationFields\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolConfigurationFields on WorkbenchToolConfiguration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n victoriaLogs {\n url\n username\n accountId\n projectId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n jira {\n email\n url\n __typename\n }\n jiraDatacenter {\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n tokenType\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n}"
},
+ "sha256:27402b003d72fdbf4fc8685fdbd96bfc3834d81b268e97cc734447c3c71629bd": {
+ "type": "mutation",
+ "name": "CreateWorkbenchSkill",
+ "body": "mutation CreateWorkbenchSkill($workbenchId: ID!, $attributes: WorkbenchSkillAttributes!) {\n createWorkbenchSkill(workbenchId: $workbenchId, attributes: $attributes) {\n id\n name\n description\n contents\n subagents\n __typename\n }\n}"
+ },
+ "sha256:743d03a4e96af969c6826afcec379d119d839f1a7fd2fc1e20f6acc5fea19276": {
+ "type": "mutation",
+ "name": "UpdateWorkbenchSkill",
+ "body": "mutation UpdateWorkbenchSkill($id: ID!, $attributes: WorkbenchSkillAttributes!) {\n updateWorkbenchSkill(id: $id, attributes: $attributes) {\n id\n name\n description\n contents\n subagents\n __typename\n }\n}"
+ },
+ "sha256:df59f492f0c473fb568226b6ddb90b9e972aa17c2cdc9b60fda54d87d28f10e3": {
+ "type": "mutation",
+ "name": "DeleteWorkbenchSkill",
+ "body": "mutation DeleteWorkbenchSkill($id: ID!) {\n deleteWorkbenchSkill(id: $id) {\n id\n __typename\n }\n}"
+ },
"sha256:9d48f13216f613d1a0265a4c8453d9ac1dfe8a3f75e1875a63d591c76afee930": {
"type": "mutation",
"name": "UpdateWorkbenchKnowledge",
diff --git a/js/console/src/graph/workbench.graphql b/js/console/src/graph/workbench.graphql
index bbfb9c3926..71dea53f81 100644
--- a/js/console/src/graph/workbench.graphql
+++ b/js/console/src/graph/workbench.graphql
@@ -1567,6 +1567,38 @@ mutation UpdateWorkbench($id: ID!, $attributes: WorkbenchAttributes!) {
}
}
+mutation CreateWorkbenchSkill(
+ $workbenchId: ID!
+ $attributes: WorkbenchSkillAttributes!
+) {
+ createWorkbenchSkill(workbenchId: $workbenchId, attributes: $attributes) {
+ id
+ name
+ description
+ contents
+ subagents
+ }
+}
+
+mutation UpdateWorkbenchSkill(
+ $id: ID!
+ $attributes: WorkbenchSkillAttributes!
+) {
+ updateWorkbenchSkill(id: $id, attributes: $attributes) {
+ id
+ name
+ description
+ contents
+ subagents
+ }
+}
+
+mutation DeleteWorkbenchSkill($id: ID!) {
+ deleteWorkbenchSkill(id: $id) {
+ id
+ }
+}
+
mutation UpdateWorkbenchKnowledge(
$id: ID!
$attributes: WorkbenchKnowledgeAttributes!
diff --git a/lib/console/ai/tools/workbench/canvas.ex b/lib/console/ai/tools/workbench/canvas.ex
index 7728805bf4..73a1e0910c 100644
--- a/lib/console/ai/tools/workbench/canvas.ex
+++ b/lib/console/ai/tools/workbench/canvas.ex
@@ -8,8 +8,8 @@ defmodule Console.AI.Tools.Workbench.Canvas do
@json_schema Console.priv_file!("tools/workbench/canvas.json") |> Jason.decode!()
def json_schema(), do: @json_schema
- def name(), do: "build_dashboard"
- def description(), do: "runs a subagent to build a dashboard to explain the system in question. Be sure to provide a detailed prompt, including the exact metrics, logs, and traces query JSON details from prior activities that will be needed in the dashboard. When trace data is available, the dashboard should use the native traces visualization block rather than markdown or ASCII output."
+ def name(), do: "canvas_subagent"
+ def description(), do: "runs a canvas subagent to build a dashboard to explain this specific job vs providing a persistent dashboard. Be sure to provide a detailed prompt, including the exact metrics, logs, and traces query JSON details from prior activities that will be needed in the dashboard. When trace data is available, the dashboard should use the native traces visualization block rather than markdown or ASCII output."
def changeset(model, attrs) do
model
diff --git a/lib/console/ai/tools/workbench/describe_component.ex b/lib/console/ai/tools/workbench/describe_component.ex
new file mode 100644
index 0000000000..c82d588c60
--- /dev/null
+++ b/lib/console/ai/tools/workbench/describe_component.ex
@@ -0,0 +1,46 @@
+defmodule Console.AI.Tools.Workbench.DescribeComponent do
+ use Console.AI.Tools.Agent.Base
+ alias Console.Repo
+ alias Console.Schema.{ServiceComponent, User}
+ alias Console.Schema.ServiceComponent.Mini
+ alias Console.Deployments.Policies
+ alias Console.AI.Tools.Workbench.Output
+
+ embedded_schema do
+ field :component_id, :string
+ end
+
+ @valid ~w(component_id)a
+
+ def changeset(model, attrs) do
+ model
+ |> cast(attrs, @valid)
+ |> validate_required(@valid)
+ |> check_uuid(:component_id)
+ end
+
+ @json_schema Console.priv_file!("tools/workbench/describe_component.json") |> Jason.decode!()
+
+ def json_schema(), do: @json_schema
+ def name(), do: "describe_component"
+ def description(), do: "Returns the Kubernetes resources belonging to a Plural service component, feel free to query any individually using the python sandbox's native k8s functions if needed."
+
+ def implement(%__MODULE__{component_id: component_id}) do
+ with {:actor, %User{} = user} <- {:actor, Tool.actor()},
+ {:comp, %ServiceComponent{} = comp} <-
+ {:comp,
+ Repo.get(ServiceComponent, component_id)
+ |> Repo.preload([:children, service: [:cluster, :repository]])},
+ {:allow, {:ok, _}} <- {:allow, Policies.allow(comp.service, user, :read)} do
+ comp
+ |> Mini.new()
+ |> Map.from_struct()
+ |> Output.json()
+ else
+ {:actor, _} -> {:error, "no user found"}
+ {:comp, _} -> {:error, "component not found or not a member of this Plural service"}
+ {:allow, _} -> {:error, "user does not have read access to this component"}
+ err -> {:error, "internal error fetching component children: #{inspect(err)}"}
+ end
+ end
+end
diff --git a/lib/console/ai/tools/workbench/integration/jira/list_transitions.ex b/lib/console/ai/tools/workbench/integration/jira/list_transitions.ex
new file mode 100644
index 0000000000..205ce1d11d
--- /dev/null
+++ b/lib/console/ai/tools/workbench/integration/jira/list_transitions.ex
@@ -0,0 +1,54 @@
+defmodule Console.AI.Tools.Workbench.Integration.Jira.ListTransitions do
+ @moduledoc false
+
+ use Console.AI.Tools.Workbench.Base
+
+ alias Console.AI.Tools.Workbench.Integration.Jira.Client
+ alias Console.Schema.WorkbenchTool
+
+ embedded_schema do
+ field :tool, :map, virtual: true
+ field :issue_id, :string
+ field :transition_id, :string
+ field :expand, {:array, :string}
+ end
+
+ @json_schema Console.priv_file!("tools/workbench/integration/jira/list_transitions.json")
+ |> Jason.decode!()
+
+ def name(%__MODULE__{tool: %WorkbenchTool{name: name}}),
+ do: "jira_#{name}_list_transitions"
+
+ def description(%__MODULE__{tool: %WorkbenchTool{name: name}}),
+ do: "List the available transitions for a Jira issue using the #{name} connection."
+
+ def json_schema(%__MODULE__{}), do: @json_schema
+
+ def changeset(model, attrs) do
+ model
+ |> cast(attrs, [:issue_id, :transition_id, :expand])
+ |> validate_required([:issue_id])
+ end
+
+ def implement(%__MODULE__{} = model) do
+ with {:ok, client} <- Client.build(model.tool),
+ {:ok, transitions} <-
+ Client.get(
+ client,
+ "/issue/#{Client.segment(model.issue_id)}/transitions",
+ query(model)
+ ) do
+ Jason.encode(transitions)
+ end
+ end
+
+ defp query(model) do
+ %{}
+ |> maybe_put(:transitionId, model.transition_id)
+ |> maybe_put(:expand, model.expand)
+ end
+
+ defp maybe_put(query, key, value) when nonempty_string(value), do: Map.put(query, key, value)
+ defp maybe_put(query, key, [_ | _] = values), do: Map.put(query, key, Enum.join(values, ","))
+ defp maybe_put(query, _, _), do: query
+end
diff --git a/lib/console/ai/tools/workbench/integration/jira/tools.ex b/lib/console/ai/tools/workbench/integration/jira/tools.ex
index f1d2ed3e6b..4591b29186 100644
--- a/lib/console/ai/tools/workbench/integration/jira/tools.ex
+++ b/lib/console/ai/tools/workbench/integration/jira/tools.ex
@@ -8,7 +8,9 @@ defmodule Console.AI.Tools.Workbench.Integration.Jira.Tools do
Console.AI.Tools.Workbench.Integration.Jira.GetIssue,
Console.AI.Tools.Workbench.Integration.Jira.SaveIssue,
Console.AI.Tools.Workbench.Integration.Jira.ListComments,
- Console.AI.Tools.Workbench.Integration.Jira.SaveComment
+ Console.AI.Tools.Workbench.Integration.Jira.SaveComment,
+ Console.AI.Tools.Workbench.Integration.Jira.ListTransitions,
+ Console.AI.Tools.Workbench.Integration.Jira.TransitionIssue
]
@spec expand(WorkbenchTool.t()) :: [struct()]
diff --git a/lib/console/ai/tools/workbench/integration/jira/transition_issue.ex b/lib/console/ai/tools/workbench/integration/jira/transition_issue.ex
new file mode 100644
index 0000000000..bf402250a1
--- /dev/null
+++ b/lib/console/ai/tools/workbench/integration/jira/transition_issue.ex
@@ -0,0 +1,61 @@
+defmodule Console.AI.Tools.Workbench.Integration.Jira.TransitionIssue do
+ @moduledoc false
+
+ use Console.AI.Tools.Workbench.Base
+
+ alias Console.AI.Tools.Workbench.Integration.Jira.Client
+ alias Console.Schema.WorkbenchTool
+
+ embedded_schema do
+ field :tool, :map, virtual: true
+ field :issue_id, :string
+ field :transition_id, :string
+ field :fields, :map, default: %{}
+ field :update, :map, default: %{}
+ end
+
+ @json_schema Console.priv_file!("tools/workbench/integration/jira/transition_issue.json")
+ |> Jason.decode!()
+
+ def name(%__MODULE__{tool: %WorkbenchTool{name: name}}),
+ do: "jira_#{name}_transition_issue"
+
+ def description(%__MODULE__{tool: %WorkbenchTool{name: name}}),
+ do: "Transition a Jira issue using the #{name} connection."
+
+ def json_schema(%__MODULE__{}), do: @json_schema
+
+ def changeset(model, attrs) do
+ model
+ |> cast(attrs, [:issue_id, :transition_id, :fields, :update])
+ |> validate_required([:issue_id, :transition_id])
+ end
+
+ def implement(%__MODULE__{} = model) do
+ with {:ok, client} <- Client.build(model.tool),
+ {:ok, response} <-
+ Client.post(
+ client,
+ "/issue/#{Client.segment(model.issue_id)}/transitions",
+ body(model)
+ ) do
+ Jason.encode(
+ Map.merge(
+ %{"issueId" => model.issue_id, "transitionId" => model.transition_id, "transitioned" => true},
+ response
+ )
+ )
+ end
+ end
+
+ defp body(model) do
+ %{"transition" => %{"id" => model.transition_id}}
+ |> maybe_put("fields", model.fields)
+ |> maybe_put("update", model.update)
+ end
+
+ defp maybe_put(body, key, value) when is_map(value) and map_size(value) > 0,
+ do: Map.put(body, key, value)
+
+ defp maybe_put(body, _, _), do: body
+end
diff --git a/lib/console/ai/tools/workbench/subagents.ex b/lib/console/ai/tools/workbench/subagents.ex
index b36a5f4599..9428c58a60 100644
--- a/lib/console/ai/tools/workbench/subagents.ex
+++ b/lib/console/ai/tools/workbench/subagents.ex
@@ -7,10 +7,12 @@ defmodule Console.AI.Tools.Workbench.Subagents do
field :job, :map, virtual: true
field :subagents, {:array, Console.AI.Tools.Workbench.Subagent.Subagent}, virtual: true
field :categories, {:array, Console.Schema.WorkbenchTool.Category}, virtual: true
+ field :tool_names, :map, virtual: true
end
@json_schema Console.priv_file!("tools/empty.json")
|> Jason.decode!()
+ @max_tool_names 15
def name(_), do: "workbench_subagents"
def json_schema(_), do: @json_schema
@@ -18,10 +20,19 @@ defmodule Console.AI.Tools.Workbench.Subagents do
def changeset(model, attrs), do: cast(model, attrs, [])
- def implement(%__MODULE__{bench: bench, job: job, subagents: subagents, categories: categories}) do
+ def implement(%__MODULE__{
+ bench: bench,
+ job: job,
+ subagents: subagents,
+ categories: categories,
+ tool_names: tool_names
+ }) do
Enum.map(subagents, fn subagent -> %{
name: subagent,
- description: subagent_description(bench, subagent, categories, job)
+ description:
+ bench
+ |> subagent_description(subagent, categories, job)
+ |> with_tool_names(Map.get(tool_names || %{}, subagent, []))
} end)
|> Jason.encode()
end
@@ -39,7 +50,7 @@ defmodule Console.AI.Tools.Workbench.Subagents do
defp subagent_description(%Workbench{configuration: %{infrastructure: %{} = infra}}, :infrastructure, _, _), do: infra_description(infra)
defp subagent_description(_, :infrastructure, _, _), do: "Invoke an infrastructure subagent to determine infrastructure state and configuration. Use this to deeply investiage kubernetes, IaaS, or Docker/OCI registry data necessary for the job at hand."
defp subagent_description(_, :observability, categories, _), do: "Invoke an observability subagent to query and analyze observability data. Supported tool capabilities are: #{observability_categories(categories)}"
- defp subagent_description(_, :monitoring, categories, _), do: "Invoke the monitoring subagent specifically to create, update, reinterpret, or delete Plural dashboards and monitors. It can inspect existing monitoring configuration and validate it with these observability capabilities: #{observability_categories(categories)}"
+ defp subagent_description(_, :monitoring, categories, _), do: "Invoke the monitoring subagent specifically to create, update, reinterpret, or delete Plural dashboards and monitors, which are durable dashboards for repeatable monitoring and investigation. It can inspect existing monitoring configuration and validate it with these observability capabilities: #{observability_categories(categories)}"
defp subagent_description(_, :integration, _, _), do: "Invoke an integration subagent to interact with enterprise systems, usually not directly related to devops infrastructure. Often Task tracking tools, knowledge bases or internal compliance software that's not SRE related."
defp subagent_description(_, :memory, _, _), do: "Invoke a memory subagent to search past workbench activities. Useful to remember what has been done so far, with regex support for finding past work."
defp subagent_description(_, :skill, _, _), do: "Invoke a skill subagent to update the skills for the current workbench. This subagent will use the skills API to update the skills for the current workbench."
@@ -65,4 +76,13 @@ defmodule Console.AI.Tools.Workbench.Subagents do
|> Enum.join(", ")
end
defp observability_categories(_), do: "not specified but likely supports metrics, logs, traces or error tracking"
+
+ defp with_tool_names(description, []), do: "#{description} Available tools: none."
+ defp with_tool_names(description, names) when length(names) > @max_tool_names do
+ {shown, hidden} = Enum.split(names, @max_tool_names)
+
+ "#{description} Available tools: #{Enum.join(shown, ", ")}, and #{length(hidden)} more."
+ end
+ defp with_tool_names(description, names) when is_list(names),
+ do: "#{description} Available tools: #{Enum.join(names, ", ")}."
end
diff --git a/lib/console/ai/tools/workbench/summarize_component.ex b/lib/console/ai/tools/workbench/summarize_component.ex
deleted file mode 100644
index d4ea153fc1..0000000000
--- a/lib/console/ai/tools/workbench/summarize_component.ex
+++ /dev/null
@@ -1,44 +0,0 @@
-defmodule Console.AI.Tools.Workbench.SummarizeComponent do
- use Console.AI.Tools.Agent.Base
- alias Console.Repo
- alias Console.Schema.{ServiceComponent, User}
- alias Console.Deployments.Policies
- alias Console.AI.Summary.Summarizable
-
- embedded_schema do
- field :prompt, :string
- field :component_id, :string
- end
-
- @valid ~w(prompt component_id)a
-
- def changeset(model, attrs) do
- model
- |> cast(attrs, @valid)
- |> validate_required([:prompt, :component_id])
- |> check_uuid(:component_id)
- end
-
- @json_schema Console.priv_file!("tools/explain/summarize_component.json") |> Jason.decode!()
-
- def json_schema(), do: @json_schema
- def name(), do: "summarize_component"
- def description(), do: "Deep introspects a given service component in kubernetes and answers any questions you might have about it. Use this as a kubernetes oracle fo this specific resource, if you don't have the ability to use this, fall back to the k8s_get and k8s_list tools available to you"
-
- def implement(%__MODULE__{prompt: prompt, component_id: component_id}) do
- with {:actor, %User{} = user} <- {:actor, Tool.actor()},
- {:comp, %ServiceComponent{} = comp} <- {:comp, Repo.get(ServiceComponent, component_id) |> Repo.preload(service: :cluster)},
- {:allow, {:ok, _}} <- {:allow, Policies.allow(comp.service, user, :read)},
- _ <- Console.AI.Evidence.Base.save_kubeconfig(comp.service.cluster),
- {:summary, {:ok, summary, true}} <- {:summary, Summarizable.summarize(comp, prompt)} do
- {:ok, summary}
- else
- {:summary, {:ok, _, false}} -> {:ok, "Could not find enough relevant information to answer the question in #{prompt}"}
- {:summary, err} -> {:error, "internal error summarizing component: #{inspect(err)}"}
- {:actor, _} -> {:error, "no user found"}
- {:comp, _} -> {:error, "component not found or not a member of this Plural service"}
- {:allow, _} -> {:error, "user does not have read access to this component"}
- err -> {:error, "internal error fetching components: #{inspect(err)}"}
- end
- end
-end
diff --git a/lib/console/ai/workbench/engine.ex b/lib/console/ai/workbench/engine.ex
index 3f5317086a..1953c874cc 100644
--- a/lib/console/ai/workbench/engine.ex
+++ b/lib/console/ai/workbench/engine.ex
@@ -488,11 +488,18 @@ defmodule Console.AI.Workbench.Engine do
categories = Environment.categories(job)
skills = Environment.with_builtins(skills) |> Environment.subagent_skills(:orchestrator)
+ tool_names = SA.tool_names(subagents, env)
skill_knowledge_tools(job, skills) ++ [
%KnowledgeUpsert{job: job},
%KnowledgeDelete{job: job},
- %Subagents{bench: job.workbench, job: job, subagents: subagents, categories: categories},
+ %Subagents{
+ bench: job.workbench,
+ job: job,
+ subagents: subagents,
+ categories: categories,
+ tool_names: tool_names
+ },
%Subagent{subagents: subagents},
%FetchNotes{job: job},
%Codemode{tools: []},
diff --git a/lib/console/ai/workbench/heartbeat.ex b/lib/console/ai/workbench/heartbeat.ex
index 3854cabcc0..ca283a2843 100644
--- a/lib/console/ai/workbench/heartbeat.ex
+++ b/lib/console/ai/workbench/heartbeat.ex
@@ -59,25 +59,41 @@ defmodule Console.AI.Workbench.Heartbeat do
end)
end
- # ReqLLM reports Bedrock Anthropic input and cache counters as separate
- # dimensions while its total only includes uncached input and output. Keep
- # the provider total when it is complete, otherwise reconstruct it before
- # AIUsage sanitization drops cache_creation_tokens.
+ # ReqLLM reports some Bedrock input and cache counters as separate dimensions
+ # while its total only includes uncached input and output. Keep the provider
+ # total when it is complete, otherwise reconstruct it before AIUsage
+ # sanitization drops the cache write counters. ReqLLM 1.25 introduced
+ # cache_read_tokens/cache_write_tokens as the canonical names while retaining
+ # cached_tokens/cache_creation_tokens as compatibility aliases.
defp normalize_usage(usage, :bedrock, model) when is_binary(model) do
- if String.contains?(model, "anthropic.") do
+ if separate_cache_counters?(usage, model) do
input = numeric(usage[:input_tokens])
output = numeric(usage[:output_tokens])
- cached = numeric(usage[:cached_tokens])
- cache_creation = numeric(usage[:cache_creation_tokens])
+ cached = counter(usage, :cache_read_tokens, :cached_tokens)
+ cache_creation = counter(usage, :cache_write_tokens, :cache_creation_tokens)
reported = numeric(usage[:total_tokens])
- Map.put(usage, :total_tokens, max(reported, input + output + cached + cache_creation))
+ usage
+ |> Map.put(:cached_tokens, cached)
+ |> Map.put(:total_tokens, max(reported, input + output + cached + cache_creation))
else
usage
end
end
defp normalize_usage(usage, _, _), do: usage
+ defp separate_cache_counters?(usage, model) do
+ Map.get(usage, :input_includes_cached, Map.get(usage, "input_includes_cached")) == false or
+ String.contains?(model, "anthropic.")
+ end
+
+ defp counter(usage, primary, fallback) do
+ case Map.get(usage, primary) do
+ value when is_number(value) -> value
+ _ -> numeric(Map.get(usage, fallback))
+ end
+ end
+
defp numeric(value) when is_number(value), do: value
defp numeric(_), do: 0
diff --git a/lib/console/ai/workbench/mcp/toolset.ex b/lib/console/ai/workbench/mcp/toolset.ex
index a4ba2b345f..6e710ec81f 100644
--- a/lib/console/ai/workbench/mcp/toolset.ex
+++ b/lib/console/ai/workbench/mcp/toolset.ex
@@ -17,7 +17,7 @@ defmodule Console.AI.Workbench.MCP.Toolset do
alias Console.AI.Workbench.Tools
alias Console.AI.Workbench.MCP.Toolset.Classify
alias Console.AI.Tools.Agent.{ServiceComponent, Stack}
- alias Console.AI.Tools.Workbench.SummarizeComponent
+ alias Console.AI.Tools.Workbench.DescribeComponent
alias Console.AI.Tools.Workbench.Infrastructure.{
ApiDiscovery,
ApiSpec,
@@ -149,7 +149,7 @@ defmodule Console.AI.Workbench.MCP.Toolset do
defp k8s_tools(%Workbench{configuration: %{infrastructure: %{kubernetes: true}}}, %User{} = user) do
[
- SummarizeComponent,
+ DescribeComponent,
%ApiDiscovery{user: user},
%ApiSpec{user: user},
%RawKubeGet{user: user},
diff --git a/lib/console/ai/workbench/mcp/toolset/classify.ex b/lib/console/ai/workbench/mcp/toolset/classify.ex
index 09c9c84733..1eb781d1c5 100644
--- a/lib/console/ai/workbench/mcp/toolset/classify.ex
+++ b/lib/console/ai/workbench/mcp/toolset/classify.ex
@@ -8,7 +8,7 @@ defmodule Console.AI.Workbench.MCP.Toolset.Classify do
workbench MCP server without someone deciding that it should.
"""
alias Console.AI.Tools.Agent.{ServiceComponent, Stack}
- alias Console.AI.Tools.Workbench.{Http, Infrastructure, Observability, SummarizeComponent}
+ alias Console.AI.Tools.Workbench.{DescribeComponent, Http, Infrastructure, Observability}
alias Console.AI.Tools.Workbench.Observability.Plrl
alias Console.AI.Tools.Workbench.Integration.{
AzureDevops,
@@ -81,7 +81,7 @@ defmodule Console.AI.Workbench.MCP.Toolset.Classify do
Infrastructure.CloudSchemas,
Infrastructure.CloudTables,
Infrastructure.RawCloudQuery,
- SummarizeComponent,
+ DescribeComponent,
ServiceComponent,
Stack
]
@@ -119,6 +119,7 @@ defmodule Console.AI.Workbench.MCP.Toolset.Classify do
Jira.GetIssue,
Jira.ListComments,
Jira.ListIssues,
+ Jira.ListTransitions,
Pagerduty.GetIncident,
Pagerduty.ListIncidentLogEntries,
Pagerduty.ListIncidentNotes,
diff --git a/lib/console/ai/workbench/subagents.ex b/lib/console/ai/workbench/subagents.ex
new file mode 100644
index 0000000000..d5514c93e8
--- /dev/null
+++ b/lib/console/ai/workbench/subagents.ex
@@ -0,0 +1,66 @@
+defmodule Console.AI.Workbench.Subagents do
+ alias Console.AI.Tool
+ alias Console.AI.Workbench.Environment
+ alias Console.AI.Workbench.Subagents.{
+ Coding,
+ History,
+ Infrastructure,
+ Integration,
+ Memory,
+ Monitoring,
+ Observability,
+ Search,
+ SelfService,
+ Skill,
+ Verify
+ }
+
+ def tool_names(subagents, %Environment{} = environment) when is_list(subagents) do
+ Map.new(subagents, &{&1, cached_tool_names(&1, environment)})
+ end
+
+ defp cached_tool_names(subagent, %Environment{job: %{id: id}} = environment)
+ when is_binary(id) do
+ key = {__MODULE__, :tool_names, id, subagent}
+
+ case Process.get(key) do
+ names when is_list(names) -> names
+ _ -> tap(resolve_tool_names(subagent, environment), &Process.put(key, &1))
+ end
+ end
+
+ defp cached_tool_names(subagent, environment),
+ do: resolve_tool_names(subagent, environment)
+
+ defp resolve_tool_names(subagent, environment) do
+ subagent
+ |> tools(environment)
+ |> Enum.map(&Tool.name/1)
+ |> Enum.uniq()
+ |> Enum.sort()
+ end
+
+ defp tools(:infrastructure, %Environment{job: job} = environment),
+ do: Infrastructure.tools(job, environment)
+
+ defp tools(:integration, environment), do: Integration.tools(environment)
+ defp tools(:coding, environment), do: Coding.tools(environment)
+ defp tools(:observability, environment), do: Observability.tools(environment)
+
+ defp tools(:monitoring, %Environment{job: job} = environment),
+ do: Monitoring.tools(environment, job)
+
+ defp tools(:memory, environment), do: Memory.tools(environment)
+
+ defp tools(:history, %Environment{job: job} = environment),
+ do: History.tools(environment, job)
+
+ defp tools(:skill, environment), do: Skill.tools(environment)
+ defp tools(:search, environment), do: Search.tools(environment)
+
+ defp tools(:verify, %Environment{job: job} = environment),
+ do: Verify.tools(job, environment)
+
+ defp tools(:self_service, environment), do: SelfService.tools(environment)
+ defp tools(_, _), do: []
+end
diff --git a/lib/console/ai/workbench/subagents/coding.ex b/lib/console/ai/workbench/subagents/coding.ex
index d34c7eb2e9..3d5d8dd225 100644
--- a/lib/console/ai/workbench/subagents/coding.ex
+++ b/lib/console/ai/workbench/subagents/coding.ex
@@ -97,7 +97,9 @@ defmodule Console.AI.Workbench.Subagents.Coding do
do: {:tool, content, %{call_id: id, name: name, arguments: args}}
defp tool_msg(content, _), do: {:user, content}
- defp tools(activity, %Environment{skills: skills, tools: workbench_tools, job: job, activities: activities}) do
+ def tools(%Environment{} = environment), do: tools(nil, environment)
+
+ def tools(activity, %Environment{skills: skills, tools: workbench_tools, job: job, activities: activities}) do
skills = Environment.subagent_skills(skills, :coding)
[
%CodingAgent{activity: activity, workbench: job.workbench, job: job, skills: skills},
diff --git a/lib/console/ai/workbench/subagents/history.ex b/lib/console/ai/workbench/subagents/history.ex
index d3da8544c7..3e078c3f96 100644
--- a/lib/console/ai/workbench/subagents/history.ex
+++ b/lib/console/ai/workbench/subagents/history.ex
@@ -34,7 +34,7 @@ defmodule Console.AI.Workbench.Subagents.History do
end
end
- defp tools(%Environment{skills: skills}, job) do
+ def tools(%Environment{skills: skills}, job) do
job = Repo.preload(job, [referenced_job: [activities: :thoughts]])
skills = Environment.subagent_skills(skills, :memory)
skill_knowledge_tools(job, skills) ++ [
diff --git a/lib/console/ai/workbench/subagents/infrastructure.ex b/lib/console/ai/workbench/subagents/infrastructure.ex
index f87b3abc0b..83d7669616 100644
--- a/lib/console/ai/workbench/subagents/infrastructure.ex
+++ b/lib/console/ai/workbench/subagents/infrastructure.ex
@@ -2,7 +2,7 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do
use Console.AI.Workbench.Subagents.Base
alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity, Workbench, User}
alias Console.AI.Tools.Workbench.{
- SummarizeComponent,
+ DescribeComponent,
Result,
Scratchpad,
History,
@@ -51,6 +51,9 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do
end
end
+ def tools(%WorkbenchJob{} = job, %Environment{} = environment),
+ do: tools(job, environment, FileCache.new())
+
defp reducer(messages, _) do
case Enum.find(messages, &match?(%Result{}, &1)) do
%Result{output: output} -> {:halt, %{
@@ -61,7 +64,7 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do
end
end
- defp tools(%WorkbenchJob{workbench: bench, user: user}, %Environment{skills: skills, job: job, activities: activities} = environment, %FileCache{} = cache) do
+ def tools(%WorkbenchJob{workbench: bench, user: user}, %Environment{skills: skills, job: job, activities: activities} = environment, %FileCache{} = cache) do
skills = Environment.subagent_skills(skills, :infrastructure)
core_tools(job, environment)
@@ -112,7 +115,7 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do
defp k8s_tools(%Workbench{configuration: %{infrastructure: %{kubernetes: true}}}, %User{} = user) do
[
- SummarizeComponent,
+ DescribeComponent,
%ApiDiscovery{user: user},
%ApiSpec{user: user},
%RawKubeGet{user: user},
diff --git a/lib/console/ai/workbench/subagents/integration.ex b/lib/console/ai/workbench/subagents/integration.ex
index 019c06e3f7..00dbf0e8a8 100644
--- a/lib/console/ai/workbench/subagents/integration.ex
+++ b/lib/console/ai/workbench/subagents/integration.ex
@@ -37,7 +37,7 @@ defmodule Console.AI.Workbench.Subagents.Integration do
end
end
- defp tools(%Environment{skills: skills, tools: tools, job: job}) do
+ def tools(%Environment{skills: skills, tools: tools, job: job}) do
skills = Environment.subagent_skills(skills, :integration)
Tools.integration_tools(tools)
diff --git a/lib/console/ai/workbench/subagents/memory.ex b/lib/console/ai/workbench/subagents/memory.ex
index f674373a0d..5b85f75605 100644
--- a/lib/console/ai/workbench/subagents/memory.ex
+++ b/lib/console/ai/workbench/subagents/memory.ex
@@ -34,7 +34,7 @@ defmodule Console.AI.Workbench.Subagents.Memory do
end
end
- defp tools(%Environment{skills: skills, activities: activities, job: job}) do
+ def tools(%Environment{skills: skills, activities: activities, job: job}) do
skill_knowledge_tools(job, Environment.subagent_skills(skills, :memory)) ++ [
Scratchpad,
%Search{activities: activities},
diff --git a/lib/console/ai/workbench/subagents/search.ex b/lib/console/ai/workbench/subagents/search.ex
index c4bab99ea4..9b2470c862 100644
--- a/lib/console/ai/workbench/subagents/search.ex
+++ b/lib/console/ai/workbench/subagents/search.ex
@@ -34,7 +34,7 @@ defmodule Console.AI.Workbench.Subagents.Search do
end
end
- defp tools(%Environment{skills: skills, tools: tools, job: job}) do
+ def tools(%Environment{skills: skills, tools: tools, job: job}) do
skills = Environment.subagent_skills(skills, :search)
MCP.expand_tools(Environment.subagent_tools(tools, :search), job)
diff --git a/lib/console/ai/workbench/subagents/self_service.ex b/lib/console/ai/workbench/subagents/self_service.ex
index 5d3b466e2c..e012573e2d 100644
--- a/lib/console/ai/workbench/subagents/self_service.ex
+++ b/lib/console/ai/workbench/subagents/self_service.ex
@@ -45,7 +45,7 @@ defmodule Console.AI.Workbench.Subagents.SelfService do
end
end
- defp tools(%Environment{skills: skills, job: job, activities: activities}) do
+ def tools(%Environment{skills: skills, job: job, activities: activities}) do
skills = Environment.subagent_skills(skills, :self_service)
[
diff --git a/lib/console/ai/workbench/subagents/skill.ex b/lib/console/ai/workbench/subagents/skill.ex
index 844c4dddf1..41eb88a16d 100644
--- a/lib/console/ai/workbench/subagents/skill.ex
+++ b/lib/console/ai/workbench/subagents/skill.ex
@@ -71,7 +71,10 @@ defmodule Console.AI.Workbench.Subagents.Skill do
"""}
end
- defp tools(target_job, %Environment{skills: skills}) do
+ def tools(%Environment{job: job, activities: activities} = environment),
+ do: tools(target_job(%{job | activities: activities}), environment)
+
+ def tools(target_job, %Environment{skills: skills}) do
skill_knowledge_tools(target_job, skills) ++ [
Scratchpad,
%SkillUpdate{skills: skills, job: target_job},
diff --git a/lib/console/ai/workbench/subagents/verify.ex b/lib/console/ai/workbench/subagents/verify.ex
index f634fc5ae9..8f423ef36d 100644
--- a/lib/console/ai/workbench/subagents/verify.ex
+++ b/lib/console/ai/workbench/subagents/verify.ex
@@ -48,7 +48,7 @@ defmodule Console.AI.Workbench.Subagents.Verify do
defp stop_msg(%Result{}), do: true
defp stop_msg(_), do: false
- defp tools(%WorkbenchJob{} = job, %Environment{skills: skills} = environment) do
+ def tools(%WorkbenchJob{} = job, %Environment{skills: skills} = environment) do
skills = Environment.subagent_skills(skills, :verify)
Observability.core_tools(job, environment)
diff --git a/mix.exs b/mix.exs
index d73eb6a4d9..5cb341acf3 100644
--- a/mix.exs
+++ b/mix.exs
@@ -170,7 +170,7 @@ defmodule Console.MixProject do
{:scribe, "~> 0.11"},
{:bandit, "~> 1.12"},
{:caramelize, "~> 1.2"},
- {:req_llm, "~> 1.24"},
+ {:req_llm, "~> 1.25"},
{:sweet_xml, ">= 0.0.0"},
{:jaqex, "~> 0.1.3"},
{:waffle, "~> 1.1", git: "https://github.com/jopedroliveira/waffle.git", tag: "v1.1.9-azure.3", override: true},
diff --git a/mix.lock b/mix.lock
index 81669db059..2ba6e97ad8 100644
--- a/mix.lock
+++ b/mix.lock
@@ -36,7 +36,7 @@
"decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"},
"deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
- "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
+ "dotenvy": {:hex, :dotenvy, "1.2.1", "43b28d17c996d70ac09f6a6dfd2245378b346ed7290c36452b46cdca77cd96bc", [:mix], [], "hexpm", "20cf780119be89a7cae7808543ebc8b7e56f0993b6aa058260fab686afe9073d"},
"earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.14.1", "7b740d87bdf45996aa0c2c2e081640906f10caa7ce5ba328fd294c7d49d0cc6f", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24b991956796700f467d0a3ef3d303138a3ef9ddddf8b98f43758ee067b20a30"},
@@ -82,7 +82,7 @@
"hammer": {:hex, :hammer, "6.1.0", "f263e3c3e9946bd410ea0336b2abe0cb6260af4afb3a221e1027540706e76c55", [:make, :mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b47e415a562a6d072392deabcd58090d8a41182cf9044cdd6b0d0faaaf68ba57"},
"hammer_plug": {:git, "https://github.com/pluralsh/hammer-plug.git", "4887091221a7addff947a9a437d2816f3ecdfd22", [branch: "runtime-config"]},
"hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"},
- "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
+ "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"},
"http_stream": {:hex, :http_stream, "1.0.0", "d40cc69eefb380508c01e004571159c26779902564558bffe1517a57764e019e", [:mix], [{:castore, "~> 0.1.7", [hex: :castore, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.7.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:mint, "~> 1.1.0", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "efa64a8800b7365a6e77b0647b289b9fa41fbbbb44a117c8ddc9b8c6fc69e126"},
"httpoison": {:git, "https://github.com/pluralsh/httpoison.git", "75f6305ce8c89b39d12574008e596785d65d4d1c", [branch: "fix-certs"]},
"hut": {:hex, :hut, "1.4.0", "7a1238ec00f95c9ec75412587ee11ac652eca308a7f4b8cc9629746d579d6cf0", [:"erlang.mk", :rebar3], [], "hexpm", "7af8704b9bae98a336f70d9560fc3c97f15665265fa603dbd05352e63d6ebb03"},
@@ -92,10 +92,10 @@
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
- "jsv": {:hex, :jsv, "0.22.0", "3a2bb35dd7d1ca0034437bee8099214e40d391a5e4f058f5a0800aef91c4dfeb", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.0 or ~> 7.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:texture, ">= 1.2.1", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "79bae1f970413c86771051a8ea0bd553cc1e866d285270be539f1ef3ca044f3c"},
+ "jsv": {:hex, :jsv, "0.25.0", "82c0594b0a080e0287b0e0412744a477ec4df3ced398673d7e77bf52b04d7765", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:idna, "~> 6.0 or ~> 7.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:texture, ">= 1.2.1", [hex: :texture, repo: "hexpm", optional: false]}], "hexpm", "d8b207d0c7d341c77e6a0732712dd754c10476acb49825cdc1b5b44ff7f4ee86"},
"kazan": {:git, "https://github.com/michaeljguarino/kazan.git", "bbeda162b85c5b49a4f044cee92070aa1670a44e", [ref: "bbeda162b85c5b49a4f044cee92070aa1670a44e"]},
"libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
- "llm_db": {:hex, :llm_db, "2026.9.4", "f56fdc8012477a0fa33247841f980c9a71e80408a5b69f63692ea238f46f8636", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "196a162cfc8826746ee84eeb395dd3a17268bfff2154ba378799bc1511e722ce"},
+ "llm_db": {:hex, :llm_db, "2026.9.7", "774b0fdbbe986d7b2073b6b32489e3fc0174e71393a396b0e6c48c4c112328e5", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}, {:zoi, "~> 0.10", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "76b451ff6ecca332905e846cbbd85aeb1832a79b8e8415019603a6567a66a649"},
"markdown_formatter": {:hex, :markdown_formatter, "0.6.0", "bc822cf262f74a70dd6c76d76407b94cd19e59ee6b83248c728d00f7e75f2bd4", [:mix], [{:earmark_parser, "~> 1.4", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "8181c6c1516061de482e24fa69e287878bab88249dae4ca489d89e914040da90"},
"mdex": {:hex, :mdex, "0.1.18", "20c5b12633e485f9a6eaf06e8d992fa186c5798be98d1405ee6a8bb6b465f59e", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "726b1aee2cc197c08f5b004d73a64d268d28837f79f8eaba74478237ddc9930f"},
"mermaid_validator": {:git, "https://github.com/pluralsh/mermaid-validator.git", "60ccf7e64fb4c1403c711fbc750f014ec29a04f9", [tag: "v0.1.2"]},
@@ -103,7 +103,7 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"},
"mimic": {:hex, :mimic, "1.11.2", "6449c3b0faee9eb95a86c50752cac4062aa002226128a6e656d020523df8b9e5", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "4856ce771c96e3d115b56f931499b2178246bbb314bcc1f80369dfda754c4912"},
- "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"},
+ "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
"mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"},
"nebulex": {:hex, :nebulex, "2.6.4", "4b00706e0e676474783d988962abf74614480e13c0a32645acb89bb32b660e09", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:shards, "~> 1.1", [hex: :shards, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "25bdabf3fb86035c8151bba60bda20f80f96ae0261db7bd4090878ff63b03581"},
@@ -157,7 +157,7 @@
"regolix": {:git, "https://github.com/pluralsh/regolix.git", "aa83d90868b71460c2e8357d8ca9c2020a0f0ebc", [tag: "master"]},
"remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"},
"req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"},
- "req_llm": {:hex, :req_llm, "1.24.0", "cdc5c5cd7f38c0e17cf7a949ced99b6760dcba0d860cd4d066fea1aeba422929", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.4", [hex: :ex_aws_auth, repo: "hexpm", optional: true]}, {:goth, "~> 1.4", [hex: :goth, repo: "hexpm", optional: true]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, ">= 2026.9.3 and < 2027.0.0", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 1.1.0", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "8ab6bda68e28afc3adf070cc6616d51831b42b8ff144116a9f104999af4c763f"},
+ "req_llm": {:hex, :req_llm, "1.25.0", "b23d90b2b15d542d9d44cb69722fd31045746dec8d4a400ce64d4b05fd5613bf", [:mix], [{:dotenvy, "~> 1.1", [hex: :dotenvy, repo: "hexpm", optional: false]}, {:ex_aws_auth, "~> 1.4", [hex: :ex_aws_auth, repo: "hexpm", optional: true]}, {:goth, "~> 1.4", [hex: :goth, repo: "hexpm", optional: true]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jsv, "~> 0.11", [hex: :jsv, repo: "hexpm", optional: false]}, {:llm_db, "~> 2026.9.6", [hex: :llm_db, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:server_sent_events, "~> 1.1.0", [hex: :server_sent_events, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:zoi, "~> 0.14", [hex: :zoi, repo: "hexpm", optional: false]}], "hexpm", "c62d4bcebf8bbd972cc28020ae2663ee074935305f7d19b70f8c08e130f0d526"},
"reverse_proxy_plug": {:hex, :reverse_proxy_plug, "3.0.2", "38fde2f59bca8b219ef4f1ec0c0849a67c6d9705160e426a2354f35399db5c7b", [:mix], [{:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.2 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.3.0 or ~> 0.4.0 or ~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "31ae5e068f7f504fba1b5c17c31c87966c720809ac15140c6c181440fbd24eda"},
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
@@ -201,6 +201,6 @@
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
"ymlr": {:hex, :ymlr, "2.0.0", "7525b6da40250777c35456017ef44f7faec06da254eafcf9f9cfb0d65f4c8cb7", [:mix], [], "hexpm", "f9301ad7ea377213b506f6e58ddffd1a7743e24238bb70e572ee510bdc2d1d5a"},
- "zoi": {:hex, :zoi, "0.18.7", "0d6b09d19fd1feff4340b7c5660bab04fbc80c1642ee1e5c75f06d527ac326db", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "5fedddd755dec84a5e78b3671070a5e595026aa3479f7fa566a42b8c4e4e5ff2"},
+ "zoi": {:hex, :zoi, "0.18.10", "35419e576865f05a59e3db095c1866b01e4fa7495702711b5435e0940e765901", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7402f8d9290ee8db19b8f8540cd8440329fcb3baacdf8ff914face3debbc5db3"},
"zstream": {:hex, :zstream, "0.6.6", "61f079476120d41512b91df68cee7881836621203a5c9169058d124c1274bf1d", [:mix], [], "hexpm", "131eab1ae2315a2d44ee8781c8107e5ee81e00ff66ee347b5a8f01d659994642"},
}
diff --git a/priv/agent-chart.tgz b/priv/agent-chart.tgz
index a48e4bfc0f..ac2cac80ab 100644
Binary files a/priv/agent-chart.tgz and b/priv/agent-chart.tgz differ
diff --git a/priv/agent-service-chart.tgz b/priv/agent-service-chart.tgz
index 4291e57616..8f610ebb45 100644
Binary files a/priv/agent-service-chart.tgz and b/priv/agent-service-chart.tgz differ
diff --git a/priv/prompts/workbench/infrastructure.md.eex b/priv/prompts/workbench/infrastructure.md.eex
index 338e87572b..c6add65a83 100644
--- a/priv/prompts/workbench/infrastructure.md.eex
+++ b/priv/prompts/workbench/infrastructure.md.eex
@@ -83,7 +83,7 @@ You are producing output for a human user, and should expect them to want to rea
## Tool Guidance
1. If you don't have enough specifics to use a precise tool like a direct kubernetes get or list, you should use the `service_search` or `stack_search` tools to gather more information. These support semantic search and work well with fuzzy inputs.
-2. In general summarize_component has the ability to dive deep into a k8s object, but you'll need the Plural-specific context from a service_search or stack_search to gather them.
+2. In general describe_component returns the child Kubernetes resources for a service component, but you'll need the Plural-specific context from a service_search or stack_search to gather its component ID.
3. We can offer the ability to directly query kubernetes, but you need to be precise with your inputs and *always* include a Plural cluster handle to make it work. It's also possible user RBAC policies block your query.
4. For questions about a Kubernetes CRD schema, call `api_discovery` with the cluster handle to identify the exact group and version, then call `api_spec` with that same handle and a kind query. The returned cluster OpenAPI schema is authoritative for fields and validation supported by that cluster.
5. If you're searching for a kubernetes resource and don't know exactly the namespace/name it might have, you can mix `service_search` with standard k8s tool calls since it has the ability to probe kubernetes objects just with a finely crafted prompt. Both are useful tools in the toolkit.
diff --git a/priv/prompts/workbench/job.md.eex b/priv/prompts/workbench/job.md.eex
index 5bf9d31500..925784bad4 100644
--- a/priv/prompts/workbench/job.md.eex
+++ b/priv/prompts/workbench/job.md.eex
@@ -9,9 +9,9 @@ the task. You'll be given the following:
1. an infrastructure search agent to probe infrastructure state and configuration. This will be useful for querying kubernetes api state and terraform state, including inspecting the installed API and CRD schemas directly from a cluster, and Docker/OCI registry metadata (tags, manifests) when those tools are configured. It is **not** for querying observability systems like log stores or metrics stores, and it **cannot** read source code repositories.
2. a coding agent to either analyze or modify code, generating a pull request<%= if @review do %>, or to review an existing pull request<% end %>. This is the only subagent that can read files in a git repository
3. an observability agent, to probe observability systems and analyze the outputs
- 4. a monitoring agent, specifically to inspect, create, update, reinterpret, or delete persistent Plural dashboards and monitors. Use it whenever the requested result should remain available for repeat use, future monitoring, or alerting. Use observability for investigation and monitoring for changing dashboard or alerting configuration.
+ 4. a monitoring agent, specifically to inspect, create, update, reinterpret, or delete *persistent* Plural dashboards and monitors. Use it whenever the requested result should remain available for repeat use, future monitoring, or alerting. Use observability for investigation and monitoring for changing dashboard or alerting configuration.
5. an integration tooling agent, to interact with business tools via apis and known interfaces. Useful for things like reporting or interacting with internal systems if present, usually task tracking tools, knowledge bases or internal compliance software that's not SRE related. **If you want to fetch observability-related data, use the observability agent instead.**
- 6. a canvas agent which creates a temporary, job-specific visual explanation of the current investigation. Use it to explain this job's evidence or conclusion, and default to it for non-trivial investigations where observability data, multiple evidence sources, or important relationships benefit from visual presentation. A canvas is not a saved monitoring dashboard and must never be used to satisfy a request to create or maintain a reusable dashboard or monitor; use the monitoring agent for those requests.
+ 6. a canvas subagent which creates a job-specific visual explanation of the current investigation. Use it to explain this job's evidence or conclusion, and default to it for non-trivial investigations where observability data, multiple evidence sources, or important relationships benefit from visual presentation. A canvas explains a specific job and is distinct from reusable monitoring configuration; use the monitoring agent when the request is to create or maintain a dashboard or monitor.
7. the ability to record notes and modify your current plan and working theory of how to accomplish the task along the way to memorize progress.
8. a search agent to search the web for information. Useful to find information that is not already in the workbench environment.
9. a verification agent which will be present when a change relevant to this job has been fully applied. This is not always present, and will be included when verification is possible.
@@ -24,7 +24,7 @@ Basic guardrails for using these subagents:
* Infrastructure should only be used for querying kubernetes api state, Kubernetes API and CRD schemas, terraform state, and Docker/OCI registry metadata. It should answer questions about custom-resource fields against the schema installed on the relevant cluster. It should **not** be used for querying observability systems like log stores or metrics stores; use the observability agent for that.
* All log, timeseries and trace data should be queried using the observability agent.
* Delegate persistent dashboard and monitor creation, updates, imports, and deletion to the monitoring agent. This includes any dashboard intended for repeat use, reopening later, ongoing monitoring, or sharing as monitoring configuration. It can query observability data itself to validate the configuration it manages.
-* Use the canvas agent for an ephemeral visual explanation attached to this specific job. Default to canvas for non-trivial investigations with informative metrics, logs, traces, multiple evidence sources, or conclusions that benefit from visual organization. If the user says "create a dashboard" and does not explicitly ask for a one-off explanation of the current job, prefer the monitoring agent. Never substitute a canvas for a persistent dashboard or monitor.
+* Use the canvas subagent for a visual explanation attached to this specific job. Default to the canvas subagent for non-trivial investigations with informative metrics, logs, traces, multiple evidence sources, or conclusions that benefit from visual organization. If the user says "create a dashboard" and does not explicitly ask for an explanation specific to the current job, prefer the monitoring agent. Never substitute a canvas for a persistent dashboard or monitor.
* When you're ready to inspect code, leverage the coding agent to either analyze or modify code, generating a pull request in write mode.
<%= if @self_service do %>
* When the task is clear, repeatable GitOps provisioning and a catalog PR automation likely covers it, prefer the self-service agent. If the pathway is undefined or needs custom code changes, use the coding agent instead.
@@ -87,25 +87,28 @@ You are producing output for a human user, and should expect them to want to rea
As work is being done, you can use the `working_theory` field to record the current best guess at the solution to your task. This is useful for tracking work for users, and as a memo to track your work for future investigation.
-Also, there's a topology field, which accepts a mermaid formatted diagram. Use this to express the current state of the system in question, including all main components at issue. It will make it much easier for users to
-understand the correctness of any theory by visualizing what is at play. You can modify this as work is being done, and also at completion to ensure your final conclusions are reflected in the diagram.
-
The `criticism` field (optional) is for limitations, risks, or things a follow-up review should know about your conclusion—use it when the answer is directionally right but incomplete or uncertain.
-Topology is useful when:
+If the task requires cross-referencing meaningful observability data, delegate to the observability agent to gather the evidence and salient queries. For a non-trivial investigation, use the canvas subagent to turn that evidence into a job-specific visual explanation. When the user asks to create or maintain a persistent workbench dashboard or monitor, delegate the evidence and exact requirements to the monitoring agent instead.
-1. There's a high level distributed system architecture to express
-2. You want to explain network flow or inter-relationship of other components.
-3. It can be ignored if no significant complexity is present.
+## Completion Criteria
+Only call `workbench_complete` if you believe all work has been completed, we can gather additional data and insights on future iterations.
-The mermaid syntax will be validated by an internal syntax checker. If you repeatedly get errors, feel free to ignore, no topology is better than an invalid one.
+Before completion, explicitly evaluate whether the job needs the canvas subagent. The canvas subagent is required when observability evidence materially supports the conclusion, or when a complex report has supporting data that benefits from visual organization. A job meeting either condition is incomplete until the canvas subagent has presented that evidence. Skip it only for trivial or text-only results, or when the requested deliverable is solely a persistent dashboard or monitor handled by the monitoring agent.
-If the task requires cross-referencing meaningful observability data, delegate to the observability agent to gather the evidence and salient queries. For a non-trivial investigation, use the canvas agent to turn that evidence into a temporary visual explanation. When the user asks to create or maintain a persistent workbench dashboard or monitor, delegate the evidence and exact requirements to the monitoring agent instead.
+Typical cases that require the canvas subagent include:
-## Completion Criteria
-Only call `workbench_complete` if you believe all work has been completed, we can gather additional data and insights on future iterations.
+* Cost reports with per-SKU breakdowns, trends, cost drivers, or other supporting evidence.
+* Root cause analyses that need to visualize the timeseries, trace, and log evidence supporting the conclusion.
+* Larger infrastructure or system queries where resource relationships, distributions, or other supporting data benefit from visual explanation.
+* Capacity, performance, or reliability assessments that compare services, regions, environments, or time windows.
+* Security, compliance, or risk reports that need to organize findings by severity, affected resources, and supporting evidence.
+
+## Coding Agent Guidance
-A change to infrastructure will usually be done via a Pull Request. Some guidance there:
+The coding agent is one of your more powerful tools and should be preferred for analyzing and modifying code unless within specific self-service golden paths.
+
+Many changes to infrastructure will usually be done via a Pull Request. Some guidance there:
<%= if @review do %>
* Pull request review is in scope for this job. Launch the coding subagent to review PRs rather than treating review as out of band.
@@ -116,7 +119,6 @@ A change to infrastructure will usually be done via a Pull Request. Some guidanc
<% end %>
* If you need to divide work into separate PRs, you can create all of them in one go without waiting for merge or completion. Reviewers will handle merge order with adequate guidance.*
* Do not spin waiting for PRs to be merged, this will just waste tokens and cause annoyance.*equests are required to accomplish what the user intends.
-* You will not have the ability to update PRs once created. SCM integration tools are read-only by design, and post PR creation feedback is delegated entirely to coding agents in babysit mode if applied.
<%= if @review do %>
* Do not re-review a PR you have already reviewed in this job unless the user asks. Trust prior review results.
<% else %>
@@ -128,7 +130,7 @@ A change to infrastructure will usually be done via a Pull Request. Some guidanc
1. Generate all PRs necessary to accomplish the task *if it requires an infrastructure or app code modification*. Expect them to be merged out of band or to reprompted if more changes are required.
2. Do not expect the infrastructure or app code to have reflected that change soon after the PR is generated, it needs to be approved, merged, applied. All of that takes time *and requires a new task*.
-3. If no change is required, the task is more about gathering information and metrics. Default to a temporary canvas when a non-trivial investigation has informative observability data, multiple evidence sources, or a conclusion that benefits from visual organization. If the result should be reusable for future monitoring, delegate creation of a persistent dashboard to the monitoring agent instead.
+3. If no change is required, the task is more about gathering information and metrics. Default to the canvas subagent when a non-trivial investigation has informative observability data, multiple evidence sources, or a conclusion that benefits from visual organization. If the result should be reusable for future monitoring, delegate creation of a persistent dashboard to the monitoring agent instead.
For tasks that do not require a code change, usually a successful run is done when all necessary evidence has been aggregated and you've come up with an adequate way of displaying the needed data to support a conclusion.
@@ -215,28 +217,6 @@ You also have a `skill_backfill` tool available. Usage of this tool has a few r
2. Use the job's history to build the appropriate prompt to do the backfill. You can invoke another subagent first to do so, if additional context is needed. Ofentimes there's some back and forth that makes it pretty obvious.
3. Report any skill updates back to the user so they are aware of what was added or updated.
<% end %>
-
-**IDEAL OUTPUT**
-You should always return a markdown formatted conclusion based on the investigation. That said, it's also extremely important to include either:
-
-* the metrics query that was most useful for informing your conclusion.
-* Prior observability subagent results will have the metrics and logs queries they've reported were salient, you should leverage that as ground truth (don't invent anything outside it).
-* the log data that informs that conclusion
-
-If either were present in the investigation, *always* include them in your result. If neither are relevant, you can omit them.
-
-In addition, use a temporary canvas (via the canvas tool) by default when it will help convey the result of a non-trivial investigation. It is an explanatory job artifact, not persistent monitoring configuration. The criteria for including it should be:
-
-1. Logs, trace or metrics data was informative to the investigation at hand. You should then aggregate that in the temporary canvas for the user to clearly see.
-2. The investigation has a meaningful amount of data involved that needs to be organized and summarized visually, in which case usage of the canvas' pie/bar chart capabilities can be useful even without an observability data component.
-3. Skip canvas only when the investigation is trivial or text-only and can be explained clearly without visual organization. This includes jobs with minimal supporting data needed to validate the correctness of the work.
-
-Users might call this explanatory artifact a canvas, visualization, or one-off dashboard. Choose canvas when the request or resulting evidence is about explaining the current job. Treat an unqualified request to create a dashboard or monitor as persistent and use the monitoring agent. You can also consult the canvas skill for guidance on how to effectively prompt it.
-
-**Before `workbench_complete`, call the canvas tool for non-trivial investigations with informative observability data, multiple evidence sources, or conclusions that benefit from visual organization. Skip canvas only for trivial or text-only investigations, or when the requested deliverable is solely a persistent dashboard or monitor handled by the monitoring agent. The investigation stops after `workbench_complete` is called.**
-
-**DO NOT** include time series data if it's just a few disconnected datapoints, it should be a real sequential time series, otherwise users will consider it noise.
-
<%= if @engine.iteration >= @engine.max_iterations - 5 do %>
## Investigation Ending
This workbench only supports <%= @engine.max_iterations %> iterations and <%= @engine.iteration %> iterations have been completed.
diff --git a/priv/tools/workbench/describe_component.json b/priv/tools/workbench/describe_component.json
new file mode 100644
index 0000000000..a3fcc26167
--- /dev/null
+++ b/priv/tools/workbench/describe_component.json
@@ -0,0 +1,10 @@
+{
+ "type": "object",
+ "properties": {
+ "component_id": {
+ "type": "string",
+ "description": "The Plural service component ID whose child Kubernetes resources should be returned"
+ }
+ },
+ "required": ["component_id"]
+}
diff --git a/priv/tools/workbench/integration/jira/list_transitions.json b/priv/tools/workbench/integration/jira/list_transitions.json
new file mode 100644
index 0000000000..9d6e5b3fd7
--- /dev/null
+++ b/priv/tools/workbench/integration/jira/list_transitions.json
@@ -0,0 +1,20 @@
+{
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "issue_id": {
+ "type": "string",
+ "description": "Jira issue key or numeric ID, for example ENG-123."
+ },
+ "transition_id": {
+ "type": "string",
+ "description": "Optional transition ID to filter the results."
+ },
+ "expand": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Jira entities to expand, such as transitions.fields for transition screen requirements."
+ }
+ },
+ "required": ["issue_id"]
+}
diff --git a/priv/tools/workbench/integration/jira/transition_issue.json b/priv/tools/workbench/integration/jira/transition_issue.json
new file mode 100644
index 0000000000..e5e76f1ee2
--- /dev/null
+++ b/priv/tools/workbench/integration/jira/transition_issue.json
@@ -0,0 +1,25 @@
+{
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "issue_id": {
+ "type": "string",
+ "description": "Jira issue key or numeric ID, for example ENG-123."
+ },
+ "transition_id": {
+ "type": "string",
+ "description": "ID of an available transition returned by list_transitions."
+ },
+ "fields": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Values for fields required by the transition screen."
+ },
+ "update": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Jira field update operations to apply with the transition."
+ }
+ },
+ "required": ["issue_id", "transition_id"]
+}
diff --git a/test/console/ai/tools/workbench/canvas_test.exs b/test/console/ai/tools/workbench/canvas_test.exs
index 4dfea20cc6..c26cee35d7 100644
--- a/test/console/ai/tools/workbench/canvas_test.exs
+++ b/test/console/ai/tools/workbench/canvas_test.exs
@@ -6,7 +6,7 @@ defmodule Console.AI.Tools.Workbench.CanvasTest do
describe "implement/1" do
test "returns the parsed struct unchanged so the engine can dispatch it" do
- model = %Canvas{id: %Tool{id: "call_1", name: "build_dashboard", arguments: %{}}, prompt: "go"}
+ model = %Canvas{id: %Tool{id: "call_1", name: "canvas_subagent", arguments: %{}}, prompt: "go"}
assert {:ok, ^model} = Canvas.implement(model)
end
diff --git a/test/console/ai/tools/workbench/describe_component_test.exs b/test/console/ai/tools/workbench/describe_component_test.exs
new file mode 100644
index 0000000000..c00d792060
--- /dev/null
+++ b/test/console/ai/tools/workbench/describe_component_test.exs
@@ -0,0 +1,54 @@
+defmodule Console.AI.Tools.Workbench.DescribeComponentTest do
+ use Console.DataCase, async: true
+ alias Console.AI.Tool
+ alias Console.AI.Tools.Workbench.DescribeComponent
+
+ describe "implement/1" do
+ test "returns a pruned component with simplified children and service details" do
+ user = insert(:user, roles: %{admin: true})
+ component = insert(:service_component)
+
+ insert(:service_component_child,
+ component: component,
+ uid: Ecto.UUID.generate(),
+ kind: "Deployment",
+ name: "api"
+ )
+
+ Tool.context(user: user)
+
+ assert {:ok, json} =
+ DescribeComponent.implement(%DescribeComponent{component_id: component.id})
+
+ assert %{
+ "component_id" => component_id,
+ "service_id" => service_id,
+ "children" => [
+ %{
+ "group" => "networking.k8s.io",
+ "version" => "v1",
+ "kind" => "Deployment",
+ "namespace" => "name",
+ "name" => "api"
+ }
+ ],
+ "service" => %{
+ "name" => service_name,
+ "cluster" => %{"name" => cluster_name, "handle" => cluster_handle},
+ "git" => %{"url" => repository_url, "ref" => "main", "folder" => "k8s"}
+ }
+ } = result = Jason.decode!(json)
+
+ assert component_id == component.id
+ assert service_id == component.service_id
+ assert service_name == component.service.name
+ assert cluster_name == component.service.cluster.name
+ assert cluster_handle == component.service.cluster.handle
+ assert repository_url == component.service.repository.url
+
+ refute Map.has_key?(result, "id")
+ refute Map.has_key?(result, "inserted_at")
+ refute Map.has_key?(result, "updated_at")
+ end
+ end
+end
diff --git a/test/console/ai/tools/workbench/integration/jira/tools_test.exs b/test/console/ai/tools/workbench/integration/jira/tools_test.exs
index cc1de23498..8d0d2f217e 100644
--- a/test/console/ai/tools/workbench/integration/jira/tools_test.exs
+++ b/test/console/ai/tools/workbench/integration/jira/tools_test.exs
@@ -9,9 +9,11 @@ defmodule Console.AI.Tools.Workbench.Integration.Jira.ToolsTest do
GetIssue,
ListComments,
ListIssues,
+ ListTransitions,
SaveComment,
SaveIssue,
- Tools
+ Tools,
+ TransitionIssue
}
alias Console.Schema.WorkbenchTool
@@ -29,8 +31,10 @@ defmodule Console.AI.Tools.Workbench.Integration.Jira.ToolsTest do
"jira_#{type}_get_issue",
"jira_#{type}_list_comments",
"jira_#{type}_list_issues",
+ "jira_#{type}_list_transitions",
"jira_#{type}_save_comment",
- "jira_#{type}_save_issue"
+ "jira_#{type}_save_issue",
+ "jira_#{type}_transition_issue"
])
refute Enum.any?(names, &String.contains?(&1, "delete"))
@@ -38,7 +42,15 @@ defmodule Console.AI.Tools.Workbench.Integration.Jira.ToolsTest do
end
test "registers with integration tools and classifies only reads as read-only" do
- [list_issues, get_issue, save_issue, list_comments, save_comment] =
+ [
+ list_issues,
+ get_issue,
+ save_issue,
+ list_comments,
+ save_comment,
+ list_transitions,
+ transition_issue
+ ] =
WorkbenchTools.integration_tools([workbench_tool(:jira)])
assert %ListIssues{} = list_issues
@@ -46,12 +58,16 @@ defmodule Console.AI.Tools.Workbench.Integration.Jira.ToolsTest do
assert %SaveIssue{} = save_issue
assert %ListComments{} = list_comments
assert %SaveComment{} = save_comment
+ assert %ListTransitions{} = list_transitions
+ assert %TransitionIssue{} = transition_issue
assert Classify.readonly?(list_issues)
assert Classify.readonly?(get_issue)
assert Classify.readonly?(list_comments)
+ assert Classify.readonly?(list_transitions)
refute Classify.readonly?(save_issue)
refute Classify.readonly?(save_comment)
+ refute Classify.readonly?(transition_issue)
end
test "save_issue requires create fields but permits partial updates" do
@@ -155,6 +171,77 @@ defmodule Console.AI.Tools.Workbench.Integration.Jira.ToolsTest do
assert %{"id" => "42"} = Jason.decode!(encoded)
end
+ test "list_transitions gets available Cloud transitions with filters" do
+ expect(Req, :request, fn opts ->
+ assert opts[:method] == :get
+
+ assert opts[:url] ==
+ "https://example.atlassian.net/rest/api/2/issue/ENG-1/transitions" <>
+ "?expand=transitions.fields&transitionId=21"
+
+ {:ok,
+ %Req.Response{
+ status: 200,
+ body: ~s({"transitions":[{"id":"21","name":"In Progress"}]})
+ }}
+ end)
+
+ assert {:ok, model} =
+ %ListTransitions{tool: workbench_tool(:jira)}
+ |> ListTransitions.changeset(%{
+ "issue_id" => "ENG-1",
+ "transition_id" => "21",
+ "expand" => ["transitions.fields"]
+ })
+ |> Ecto.Changeset.apply_action(:update)
+
+ assert {:ok, encoded} = ListTransitions.implement(model)
+ assert %{"transitions" => [%{"id" => "21"}]} = Jason.decode!(encoded)
+ end
+
+ test "transition_issue posts a Data Center transition with fields and updates" do
+ expect(Req, :request, fn opts ->
+ assert opts[:method] == :post
+ assert opts[:url] == "https://jira.example.com/rest/api/2/issue/ENG-1/transitions"
+
+ assert Jason.decode!(opts[:body]) == %{
+ "transition" => %{"id" => "21"},
+ "fields" => %{"resolution" => %{"name" => "Done"}},
+ "update" => %{"comment" => [%{"add" => %{"body" => "Completed"}}]}
+ }
+
+ {:ok, %Req.Response{status: 204, body: ""}}
+ end)
+
+ assert {:ok, model} =
+ %TransitionIssue{tool: workbench_tool(:jira_datacenter)}
+ |> TransitionIssue.changeset(%{
+ "issue_id" => "ENG-1",
+ "transition_id" => "21",
+ "fields" => %{"resolution" => %{"name" => "Done"}},
+ "update" => %{"comment" => [%{"add" => %{"body" => "Completed"}}]}
+ })
+ |> Ecto.Changeset.apply_action(:update)
+
+ assert {:ok, encoded} = TransitionIssue.implement(model)
+
+ assert %{
+ "issueId" => "ENG-1",
+ "transitionId" => "21",
+ "transitioned" => true
+ } = Jason.decode!(encoded)
+ end
+
+ test "transition_issue requires an issue and transition ID" do
+ assert {:error, changeset} =
+ %TransitionIssue{tool: workbench_tool(:jira)}
+ |> TransitionIssue.changeset(%{})
+ |> Ecto.Changeset.apply_action(:update)
+
+ assert %{issue_id: ["can't be blank"], transition_id: ["can't be blank"]} =
+ errors_on(changeset)
+ end
+
defp workbench_tool(:jira) do
tool(:jira, %{
jira: %{
diff --git a/test/console/ai/tools/workbench/subagents_test.exs b/test/console/ai/tools/workbench/subagents_test.exs
index 4cfe100869..0e4f060bf7 100644
--- a/test/console/ai/tools/workbench/subagents_test.exs
+++ b/test/console/ai/tools/workbench/subagents_test.exs
@@ -2,6 +2,8 @@ defmodule Console.AI.Tools.Workbench.SubagentsTest do
use Console.DataCase, async: true
alias Console.AI.Tool
+ alias Console.AI.Workbench.Environment
+ alias Console.AI.Workbench.Subagents, as: RuntimeSubagents
alias Console.AI.Tools.Workbench.{
ObservabilityResult,
Result,
@@ -48,6 +50,51 @@ defmodule Console.AI.Tools.Workbench.SubagentsTest do
end
describe "implement/1" do
+ test "lists the cached applicable tool names in each subagent description" do
+ environment = %Environment{
+ job: %WorkbenchJob{workbench: %Workbench{}},
+ tools: %{},
+ skills: %{},
+ activities: []
+ }
+
+ tool_names = RuntimeSubagents.tool_names([:memory], environment)
+
+ {:ok, encoded} =
+ Subagents.implement(%Subagents{
+ bench: %Workbench{},
+ job: environment.job,
+ subagents: [:memory],
+ categories: [],
+ tool_names: tool_names
+ })
+
+ assert [%{"name" => "memory", "description" => description}] =
+ Jason.decode!(encoded)
+
+ assert description =~ "Available tools: #{Enum.join(tool_names.memory, ", ")}."
+ assert "agent_scratchpad" in tool_names.memory
+ assert "subagent_result" in tool_names.memory
+ assert "workbench_activity_search" in tool_names.memory
+ end
+
+ test "limits displayed tool names to 15" do
+ names = Enum.map(1..16, &"tool_#{String.pad_leading("#{&1}", 2, "0")}")
+
+ {:ok, encoded} =
+ Subagents.implement(%Subagents{
+ bench: %Workbench{},
+ job: %WorkbenchJob{},
+ subagents: [:memory],
+ categories: [],
+ tool_names: %{memory: names}
+ })
+
+ assert [%{"description" => description}] = Jason.decode!(encoded)
+ assert description =~ "tool_15, and 1 more."
+ refute description =~ "tool_16"
+ end
+
test "describes infrastructure as covering kubernetes, IaaS, and Docker/OCI" do
{:ok, encoded} =
Subagents.implement(%Subagents{
diff --git a/test/console/ai/workbench/engine_test.exs b/test/console/ai/workbench/engine_test.exs
index d5ac734bd4..b0c0df7ef5 100644
--- a/test/console/ai/workbench/engine_test.exs
+++ b/test/console/ai/workbench/engine_test.exs
@@ -513,7 +513,7 @@ defmodule Console.AI.Workbench.EngineTest do
assert result.status == :successful
end
- test "dispatches build_dashboard tool calls, persists the canvas activity, and completes the job" do
+ test "dispatches canvas_subagent tool calls, persists the canvas activity, and completes the job" do
deployment_settings(
logging: %{enabled: true, driver: :elastic, elastic: es_settings()},
ai: %{
@@ -532,7 +532,7 @@ defmodule Console.AI.Workbench.EngineTest do
{:ok, "build a dashboard", [
%Tool{
id: "1",
- name: "build_dashboard",
+ name: "canvas_subagent",
arguments: %{"prompt" => "build a dashboard summarizing /ping 500s"}
}
]}
@@ -565,7 +565,7 @@ defmodule Console.AI.Workbench.EngineTest do
assert canvas, "expected a canvas activity to be created"
assert canvas.status == :successful, "canvas activity should be marked :successful, not left :pending"
assert canvas.prompt == "build a dashboard summarizing /ping 500s"
- assert canvas.tool_call.name == "build_dashboard"
+ assert canvas.tool_call.name == "canvas_subagent"
assert canvas.result.output == "canvas subagent result"
end
@@ -586,7 +586,7 @@ defmodule Console.AI.Workbench.EngineTest do
expect_reqllm_completion(fn _, _ ->
{:ok, "build a dashboard", [
- %Tool{id: "1", name: "build_dashboard", arguments: %{"prompt" => "go"}}
+ %Tool{id: "1", name: "canvas_subagent", arguments: %{"prompt" => "go"}}
]}
end)
diff --git a/test/console/ai/workbench/heartbeat_test.exs b/test/console/ai/workbench/heartbeat_test.exs
index cb5077add1..1fde48a9bf 100644
--- a/test/console/ai/workbench/heartbeat_test.exs
+++ b/test/console/ai/workbench/heartbeat_test.exs
@@ -48,6 +48,96 @@ defmodule Console.AI.Workbench.HeartbeatTest do
assert usage.total_tokens == 687_824
end
+ test "normalizes ReqLLM canonical Bedrock cache counters" do
+ job = insert(:workbench_job, status: :running)
+ {:ok, pid} = Heartbeat.start_link(job)
+ Process.unlink(pid)
+
+ on_exit(fn ->
+ if Process.alive?(pid), do: GenServer.stop(pid, :normal)
+ end)
+
+ Heartbeat.usage_callback(
+ job,
+ :bedrock,
+ "anthropic.claude-sonnet-4-6",
+ nil,
+ %{
+ input_tokens: 824,
+ output_tokens: 19_000,
+ total_tokens: 19_824,
+ cache_read_tokens: 667_000,
+ cache_write_tokens: 1_000
+ }
+ )
+
+ %{usage: usage} = :sys.get_state(pid)
+
+ assert usage.input_tokens == 824
+ assert usage.output_tokens == 19_000
+ assert usage.cached_tokens == 667_000
+ assert usage.total_tokens == 687_824
+ end
+
+ test "normalizes separate cache counters for non-Anthropic Bedrock Converse models" do
+ job = insert(:workbench_job, status: :running)
+ {:ok, pid} = Heartbeat.start_link(job)
+ Process.unlink(pid)
+
+ on_exit(fn ->
+ if Process.alive?(pid), do: GenServer.stop(pid, :normal)
+ end)
+
+ Heartbeat.usage_callback(
+ job,
+ :bedrock,
+ "amazon.nova-pro-v1:0",
+ nil,
+ %{
+ input_tokens: 100,
+ output_tokens: 50,
+ total_tokens: 150,
+ cache_read_tokens: 600,
+ cache_write_tokens: 25,
+ input_includes_cached: false
+ }
+ )
+
+ %{usage: usage} = :sys.get_state(pid)
+
+ assert usage.cached_tokens == 600
+ assert usage.total_tokens == 775
+ end
+
+ test "does not add cache counters when Bedrock input already includes them" do
+ job = insert(:workbench_job, status: :running)
+ {:ok, pid} = Heartbeat.start_link(job)
+ Process.unlink(pid)
+
+ on_exit(fn ->
+ if Process.alive?(pid), do: GenServer.stop(pid, :normal)
+ end)
+
+ Heartbeat.usage_callback(
+ job,
+ :bedrock,
+ "openai.gpt-oss-120b-1:0",
+ nil,
+ %{
+ input_tokens: 100,
+ output_tokens: 50,
+ total_tokens: 150,
+ cached_tokens: 60,
+ input_includes_cached: true
+ }
+ )
+
+ %{usage: usage} = :sys.get_state(pid)
+
+ assert usage.cached_tokens == 60
+ assert usage.total_tokens == 150
+ end
+
test "preserves a larger provider total for Bedrock Anthropic usage" do
job = insert(:workbench_job, status: :running)
{:ok, pid} = Heartbeat.start_link(job)