mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 00:03:21 +00:00
* feat: split ai agent memory into agent policy, run memory id and step history * fix: scope string memory ids to workspace and flow, keep nested tool history inputs * chore: update sqlx cache for the flow context query * docs: describe memory id scoping as collision-free rather than isolated * chore: regenerate openflow json after merging main * fix: offer no memory id for legacy manual memory, document linked history inputs * fix: seed provided messages from legacy manual memory and hide its note once set * fix: bypass memory when a provided messages expression evaluates to null * fix: require a user message when provided messages are empty * chore: keep the empty messages comment within the line width * docs: name the history inputs wherever linked steps list their flow-local inputs * docs: keep the memory storage path on one line * feat: managed memory with an inherited or custom memory id per step Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: list a custom memory id in the test run form and name where an inherited one comes from Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep memory id out of the add-field menu and drop the memory id telemetry Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep legacy auto memory without an id working after an untouched redeploy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: rename step messages to previous_messages and address review Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style: rewrap comments and docs lines lengthened by the previous_messages rename Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: read agent memory as either a legacy shape or the current one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: name the memory setting in ignored-input notes and keep conversions honest Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep a legacy memory count unset on open and read a cleared count as off Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: address review on cleared test history and zero-count memory Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: drop flow-local keys from a linked agent resource before interpolating it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: keep a linked resource's own inputs as fallbacks and note ignored history on image runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: restore the linked agent draft tests and log ignored history on every image run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: resolve the one-of variant from the value when the selected one leaves the list Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: treat zero-count managed memory as off when enabling chat mode and shorten comments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: stop requiring user_message in the openflow agent contract when previous messages are the prompt Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
import { deepEqual } from 'fast-equals'
|
||
import type { InputTransform } from '$lib/gen'
|
||
import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields'
|
||
|
||
// The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs below are
|
||
// intentionally excluded — they are supplied per-flow.
|
||
export const AGENT_BRAIN_KEYS = [
|
||
'provider',
|
||
'output_type',
|
||
'system_prompt',
|
||
'streaming',
|
||
'memory',
|
||
'output_schema',
|
||
'max_completion_tokens',
|
||
'temperature',
|
||
'max_iterations'
|
||
] as const
|
||
|
||
/**
|
||
* The inputs a step supplies for itself, whether or not it is linked to a saved agent.
|
||
*
|
||
* `enabled_tools` is one of them because it narrows one use of an agent rather than the agent:
|
||
* saving it into the resource would impose one flow's roster on every flow linking it. The history
|
||
* inputs are too, since which conversation a step reads belongs to the flow using the agent.
|
||
*/
|
||
export const AGENT_FLOW_LOCAL_KEYS = [
|
||
'user_message',
|
||
'user_attachments',
|
||
'enabled_tools',
|
||
'memory_id',
|
||
'previous_messages'
|
||
] as const
|
||
|
||
export type AgentTool = Record<string, any>
|
||
|
||
/**
|
||
* Why this resource cannot be edited as an agent, if it cannot. A path with no deployed row answers
|
||
* with its own draft, whose type the generic resource editor never writes, so an unknown type is
|
||
* refused rather than assumed: deploying it would create that path as an agent.
|
||
*/
|
||
export function agentEditorRefusal(
|
||
path: string,
|
||
resourceType: string | undefined
|
||
): string | undefined {
|
||
if (resourceType === 'ai_agent') return undefined
|
||
return resourceType
|
||
? `${path} is a ${resourceType} resource, not an agent.`
|
||
: `${path} has no deployed agent to edit.`
|
||
}
|
||
|
||
/** Brain keys whose step transform is non-static and would be dropped by a save-as-agent snapshot. */
|
||
export function nonStaticBrainKeys(
|
||
inputTransforms: Record<string, InputTransform> | undefined
|
||
): string[] {
|
||
return AGENT_BRAIN_KEYS.filter((key) => {
|
||
const t = inputTransforms?.[key] as any
|
||
return t && t.type !== 'static'
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Keep only the flow-local inputs on a step's input_transforms. Used when linking: the brain comes
|
||
* from the resource, so the step must not carry stale `provider`/`system_prompt`/… transforms — at
|
||
* runtime they'd still be resolved (an unnecessary, possibly failing provider `$res:` fetch) yet
|
||
* never used, since the linked branch takes the brain from the resource.
|
||
*/
|
||
export function flowLocalInputs(
|
||
inputTransforms: Record<string, InputTransform> | undefined
|
||
): Record<string, InputTransform> {
|
||
const out: Record<string, InputTransform> = {}
|
||
for (const key of AGENT_FLOW_LOCAL_KEYS) {
|
||
if (inputTransforms?.[key]) {
|
||
out[key] = inputTransforms[key]
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* The host-flow overrides to store on a linked step for one tool: the subset of the tool's edited
|
||
* input_transforms that diverges from the resource tool's own transforms. Storing only the diff (not
|
||
* the full merged map) keeps unchanged inputs inheriting from the resource, makes merely opening a
|
||
* tool a no-op (its inputs still equal base ∪ overrides), and lets reverting an edit persist.
|
||
*/
|
||
export function toolInputOverrides(
|
||
inputs: Record<string, InputTransform> | undefined,
|
||
base: Record<string, InputTransform> | undefined
|
||
): Record<string, InputTransform> {
|
||
const overrides: Record<string, InputTransform> = {}
|
||
for (const [key, value] of Object.entries(inputs ?? {})) {
|
||
if (!deepEqual(value, base?.[key])) {
|
||
overrides[key] = value
|
||
}
|
||
}
|
||
return overrides
|
||
}
|
||
|
||
export interface AIAgentConfig {
|
||
provider?: unknown
|
||
output_type?: string
|
||
system_prompt?: string
|
||
streaming?: boolean
|
||
memory?: unknown
|
||
output_schema?: unknown
|
||
max_completion_tokens?: number
|
||
temperature?: number
|
||
max_iterations?: number
|
||
tools?: AgentTool[]
|
||
}
|
||
|
||
/** Extract the static brain values from a step's input_transforms into a flat agent config. */
|
||
export function inputTransformsToAgentConfig(
|
||
inputTransforms: Record<string, InputTransform> | undefined,
|
||
tools: AgentTool[] | undefined
|
||
): AIAgentConfig {
|
||
const config: AIAgentConfig = { tools: tools ?? [] }
|
||
for (const key of AGENT_BRAIN_KEYS) {
|
||
const t = inputTransforms?.[key] as any
|
||
// `null` as well as `undefined`: a placeholder transform is `{"type":"static"}`, and it comes
|
||
// back from the API — and from a schema backfill — with an explicit null. Writing it through
|
||
// would put `memory: null` in the saved agent and show as a change against a config that
|
||
// simply omits the key.
|
||
if (t && t.type === 'static' && t.value !== undefined && t.value !== null) {
|
||
;(config as any)[key] = t.value
|
||
}
|
||
}
|
||
return config
|
||
}
|
||
|
||
/**
|
||
* Reduce the AI agent schema to only the flow-local inputs. Used when a step is linked to a saved
|
||
* agent: the brain fields come from the resource, so only `AGENT_FLOW_LOCAL_KEYS` stay editable.
|
||
*/
|
||
export function flowLocalAgentSchema(schema: any): any {
|
||
if (!schema?.properties) {
|
||
return schema
|
||
}
|
||
const properties: Record<string, unknown> = {}
|
||
for (const key of AGENT_FLOW_LOCAL_KEYS) {
|
||
if (schema.properties[key]) {
|
||
properties[key] = schema.properties[key]
|
||
}
|
||
}
|
||
return {
|
||
...schema,
|
||
properties,
|
||
order: (schema.order ?? Object.keys(properties)).filter((k: string) => k in properties),
|
||
required: (schema.required ?? []).filter((k: string) => k in properties)
|
||
}
|
||
}
|
||
|
||
/** Read off the form's own registry, so a linked agent's summary cannot name a field differently
|
||
* from the form that edits it. */
|
||
export const AGENT_BRAIN_LABELS: Record<string, string> = Object.fromEntries(
|
||
AGENT_FIELDS.map((f) => [f.key, f.label])
|
||
)
|
||
|
||
/**
|
||
* Brain keys set to an input transform rather than to a value, which a saved agent's plain JSON
|
||
* cannot hold. Matched on the tag so the whole `InputTransform` union is covered: a payload-key
|
||
* test would miss `{"type":"ai"}`, which carries none.
|
||
*/
|
||
export function transformValuedBrainKeys(args: Record<string, any> | undefined): string[] {
|
||
return AGENT_BRAIN_KEYS.filter((key) => {
|
||
const v = args?.[key]
|
||
return (
|
||
v !== null &&
|
||
typeof v === 'object' &&
|
||
(v.type === 'javascript' || v.type === 'ai' || v.type === 'static')
|
||
)
|
||
})
|
||
}
|
||
|
||
/** Flatten a saved agent's brain config into human-readable label/value rows for a read-only
|
||
* display on a linked step. Only set fields are returned, in the canonical brain-key order. */
|
||
export function summarizeAgentBrain(
|
||
config: AIAgentConfig | undefined
|
||
): { label: string; value: string }[] {
|
||
const rows: { label: string; value: string }[] = []
|
||
for (const key of AGENT_BRAIN_KEYS) {
|
||
const v = (config as any)?.[key]
|
||
if (v === undefined || v === null || v === '') continue
|
||
let value: string
|
||
if (key === 'provider') {
|
||
value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured'
|
||
} else if (key === 'memory') {
|
||
value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v)
|
||
} else if (key === 'output_schema') {
|
||
value = 'configured'
|
||
} else if (typeof v === 'boolean') {
|
||
value = v ? 'on' : 'off'
|
||
} else if (typeof v === 'object') {
|
||
value = JSON.stringify(v)
|
||
} else {
|
||
value = String(v)
|
||
}
|
||
rows.push({ label: AGENT_BRAIN_LABELS[key] ?? key, value })
|
||
}
|
||
return rows
|
||
}
|
||
|
||
/** Inverse: wrap brain config values as static input_transforms (used when unlinking a step). */
|
||
export function agentConfigToInputTransforms(
|
||
config: AIAgentConfig
|
||
): Record<string, InputTransform> {
|
||
const it: Record<string, InputTransform> = {}
|
||
for (const key of AGENT_BRAIN_KEYS) {
|
||
const v = (config as any)[key]
|
||
if (v !== undefined) {
|
||
it[key] = { type: 'static', value: v } as InputTransform
|
||
}
|
||
}
|
||
return it
|
||
}
|