Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ called out explicitly under **Changed** or **Removed**.

## [Unreleased]

### Added

- **Automation specs can give a Call LLM step an MCP server's tools:**
`mcp_server` (api_name or UUID), `run_tools_as` (a service account's name
or UUID), `max_sequential_turns` and `tool_failure_policy`. Set the server
and the account together; `--dry-run` refuses one without the other. A
spec that leaves them out gets no server, so `update` detaches one attached
in the UI. `update --dry-run` names those steps (`detaches_mcp_server=`),
and `diff` shows the change.

### Fixed

- **Automations with an AI (Call LLM) step can be shown, diffed and edited
again.** Since Kizen's 2026-10-26 release every such step carries two
settings the CLI refused, so `show`, `diff`, `steps`, `activate`,
`deactivate`, `move` and `roundtrip` stopped on any automation with one.
- **`automations update` from a spec no longer leaves an MCP-attached Call
LLM step without its run-as account.** Kizen kept the server and dropped
the account when a step's spec left both out, storing a step it then
refuses to save.

## [0.3.0] — 2026-10-21

### Changed
Expand Down
3 changes: 2 additions & 1 deletion scripts/cli-tree-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4346,7 +4346,8 @@

`--check` is the session-start form: it reports whether a newer version
exists, caches the answer for a day, and always exits 0 — offline, behind a
proxy, or with no remote configured, it just says so quietly.
proxy, or with no remote configured, it just says so quietly. Under
app-builder it says the installation is managed there, and still exits 0.

╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
│ --check Only report whether a newer version exists; change nothing. │
Expand Down
15 changes: 15 additions & 0 deletions src/kizen_builder/api/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ def get_metadata(client: KizenClient) -> dict[str, Any]:
return client.get("/api/automation2/automations/metadata")


def list_mcp_step_options(client: KizenClient) -> list[dict[str, Any]]:
"""GET /api/mcp-servers/step-options: the MCP servers a `call_llm` step
can attach, as its picker lists them (`{id, name, api_name, is_enabled,
deleted, tool_count}`). Unpaginated; the server ignores `search`."""
return client.get("/api/mcp-servers/step-options")


def list_run_tools_as_options(client: KizenClient) -> list[dict[str, Any]]:
"""GET /api/automation2/automations/run-tools-as-options: the service
accounts a `call_llm` step can run its MCP tools as (`{id, name}`).
Narrower than the service-account typeahead: a plugin's own service
account isn't listed, and the write rejects it."""
return client.get("/api/automation2/automations/run-tools-as-options")


def start_automation(
client: KizenClient,
api_name: str,
Expand Down
37 changes: 36 additions & 1 deletion src/kizen_builder/docs/specs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ to wait for a reply. Its `wait_type` is `never`, `delay` or
| `go_to_automation_step` | `action_go_to_automation_step` | jump to another step (`step_key`) |
| `code_step` | `action_code_step` | run Python (`script`, `inputs`, `outputs`, `secrets`) |
| `plugin_code_step` | `action_plugin_code_step` | run a step that an installed plugin ships |
| `call_llm` | `action_call_llm` | prompt an LLM, write to `destinations` |
| `call_llm` | `action_call_llm` | prompt an LLM, optionally with an MCP server's tools, write to `destinations` |
| `file_content_extraction` | `action_file_content_extraction` | extract from a file field on the record, a related record, or in a variable (`input_value_source`) |
| `audio_transcription` | `action_audio_transcription` | transcribe an audio field on the record, a related record, or in a variable (`input_value_source`) |
| `change_field_value` | `action_change_field_value` | set/clear a field |
Expand Down Expand Up @@ -538,6 +538,41 @@ still runs fine via the raw API, but its rich-text prompt editor renders
blank in the builder UI without this. Pass `html_prompt` explicitly to
override the derivation.

**Tools (MCP server)** — a `call_llm` step can let the model call an MCP
server's tools. Confirmed live 2026-10-27:

| Spec key | Wire key | Value |
|---|---|---|
| `mcp_server` | `mcp_server_id` | the server's `api_name` or UUID |
| `run_tools_as` | `run_tools_as_id` | the service account's `name` or UUID |
| `max_sequential_turns` | same | 1–32767, default 25 |
| `tool_failure_policy` | same | `never_fail`, the only value Kizen accepts |

- Names resolve against the step's own pickers:
`GET /api/mcp-servers/step-options` for servers and
`GET /api/automation2/automations/run-tools-as-options` for accounts. The
second leaves out a plugin's own service account, and a write naming that
account is a 400. A service account resolves by `name` because a user-made
one has no `api_name`. A name two accounts share is an error; give the UUID.
The `*_id` spellings take the same values.
- Set both `mcp_server` and `run_tools_as`, or neither. Kizen 400s on either
alone, and `--dry-run` refuses it first.
- A model without tool calling 400s with "The selected model does not support
tool calling". The model catalog has no flag for it, so the server's 400 is
the only check.
- Reads expand both references (`mcp_server {id, name, api_name, …}`,
`run_tools_as {id, name}`), and every `call_llm` step reads
`max_sequential_turns` and `tool_failure_policy`, server or not.
- The planner always sends all four keys. A step that omits them gets no
server, no account, 25 turns and `never_fail`, so `update` from a spec
without them **detaches a server attached in the UI**. `update --dry-run`
names those steps in its preview: `detaches_mcp_server=[<step key>, …]`,
and `clears_run_tools_as=[…]` for a run-as account that goes. Steps pair
as `diff` pairs them, and `diff` shows each change as
`mcp_server_id: '<uuid>' → None` and `run_tools_as_id: '<uuid>' → None`.
Kizen itself, given a PUT that omits both ids, keeps the server and clears
the account, which leaves a step its own validator rejects.

### Variable-comparison condition step

A `condition` step's `filter_config` normally uses the record-field filter
Expand Down
39 changes: 39 additions & 0 deletions src/kizen_builder/models/spec/automations_actions_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,45 @@ class ActionLlmCallConfig(BaseModel):
"audio_transcription, and a condition step's llm_decision block."
),
)
mcp_server: str | dict[str, Any] | None = Field(
default=None,
description=(
"The MCP server whose tools the model may call: its api_name or "
"UUID, from the Call LLM step's own server picker "
"(GET /api/mcp-servers/step-options). Needs run_tools_as. Omitted "
"means no server, so an update detaches one set in the UI."
),
)
mcp_server_id: str | None = Field(
default=None,
description="The MCP server's UUID (the wire spelling of mcp_server).",
)
run_tools_as: str | dict[str, Any] | None = Field(
default=None,
description=(
"The service account the MCP server's tools run as: its name or "
"UUID, from the step's run-as picker "
"(GET /api/automation2/automations/run-tools-as-options), which "
"leaves out a plugin's own service account. Needs mcp_server."
),
)
run_tools_as_id: str | None = Field(
default=None,
description="The service account's UUID (the wire spelling of run_tools_as).",
)
max_sequential_turns: int | None = Field(
default=None,
ge=1,
le=32767,
description="How many model turns the step may take in a row. Default 25.",
)
tool_failure_policy: Literal["never_fail"] | None = Field(
default=None,
description=(
"What a failed tool call does to the step. 'never_fail' (the "
"default) is the only value Kizen accepts."
),
)


class ActionAudioTranscriptionConfig(BaseModel):
Expand Down
135 changes: 134 additions & 1 deletion src/kizen_builder/tools/planners/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,52 @@ def __init__(self) -> None:
# rather than a single value shape.
self._objects_by_api: dict[str, Any] = {}
self._automations_by_api: dict[str, dict[str, Any]] | None = None
self._step_pickers: dict[str, list[dict[str, Any]]] = {}

def mcp_server_uuid(self, api_name: str) -> str:
"""UUID of an MCP server a call_llm step can attach, by api_name."""
from kizen_builder.api.automations import list_mcp_step_options

options = self._step_picker("mcp_servers", list_mcp_step_options)
match = next((o for o in options if o.get("api_name") == api_name), None)
if match is None:
available = ", ".join(str(o.get("api_name")) for o in options)
raise PlanError(
f"MCP server '{api_name}' isn't one a Call LLM step can attach "
f"in '{self.env}'. Available: {available or '(none)'}"
)
return match["id"]

def service_account_uuid(self, name: str) -> str:
"""UUID of a service account a call_llm step can run its tools as,
by name: a user-made account has no api_name."""
from kizen_builder.api.automations import list_run_tools_as_options

options = self._step_picker("run_tools_as", list_run_tools_as_options)
matches = [o for o in options if o.get("name") == name]
if not matches:
available = ", ".join(str(o.get("name")) for o in options)
raise PlanError(
f"service account '{name}' can't run a Call LLM step's tools "
f"in '{self.env}'. Available: {available or '(none)'}"
)
if len(matches) > 1:
ids = ", ".join(m["id"] for m in matches)
raise PlanError(
f"service account name '{name}' matches {len(matches)} accounts "
f"in '{self.env}': {ids}. Use the id instead."
)
return matches[0]["id"]

def _step_picker(
self, name: str, fetch: Callable[[Any], list[dict[str, Any]]]
) -> list[dict[str, Any]]:
if name not in self._step_pickers:
from kizen_builder.api.client import KizenClient

with KizenClient(load_env_config()) as client:
self._step_pickers[name] = fetch(client)
return self._step_pickers[name]

def object_uuid(self, api_name: str) -> str:
return self.object_data(api_name)["id"]
Expand Down Expand Up @@ -285,6 +331,7 @@ def plan_update_automation(automation: dict[str, Any] | AutomationDef) -> Plan:
payload = _merge_server_state(_build_automation_payload(auto, ctx), current)
preview = _automation_preview(env, auto, live_active=live_active)
preview["current_revision"] = current.get("revision")
preview.update(_llm_tools_dropped(current, payload))
return Plan.build(
env=env,
summary=f"Update automation '{auto.api_name}'",
Expand Down Expand Up @@ -602,6 +649,31 @@ def _fan_out_warnings(auto: AutomationDef) -> dict[str, Any]:
return {"warnings": warnings} if warnings else {}


def _llm_tools_dropped(
current: dict[str, Any], payload: dict[str, Any]
) -> dict[str, list[str]]:
"""Call LLM steps whose live MCP server or run-as account the update
clears, by spec step key. A spec that omits the keys sends null, so an
`update` detaches a server attached in the UI. Steps pair as `diff`
pairs them: by id, then by position."""
from kizen_builder.translate import _wire_pairs

live_steps = sorted(current.get("steps") or [], key=lambda s: s.get("order") or 0)
dropped: dict[str, list[str]] = {}
for live, built in _wire_pairs(live_steps, payload.get("steps") or []):
if live is None or built is None or built.get("type") != "call_llm":
continue
was = live.get("action_call_llm") or {}
now = built.get("action_call_llm") or {}
for read_key, wire_key, cell in (
("mcp_server", "mcp_server_id", "detaches_mcp_server"),
("run_tools_as", "run_tools_as_id", "clears_run_tools_as"),
):
if was.get(read_key) and now.get(wire_key) is None:
dropped.setdefault(cell, []).append(built["key"])
return dropped


# ---------------------------------------------------------------------------
# Top-level payload builder
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1438,6 +1510,24 @@ def _built_block_problem(
return _scalar_value_resolution_problem(key, block, auto)
if step_type == "assign_team_member":
return _assign_team_member_problem(key, block, auto, ctx)
if step_type == "call_llm":
return _llm_tools_pairing_problem(key, block)
return None


def _llm_tools_pairing_problem(key: str, block: dict[str, Any]) -> str | None:
"""The write rejects a call_llm step with only one of the pair."""
server, account = block.get("mcp_server_id"), block.get("run_tools_as_id")
if server and not account:
return (
f"step '{key}': mcp_server is set without run_tools_as. Kizen runs "
"an MCP server's tools as a service account, so set both or neither."
)
if account and not server:
return (
f"step '{key}': run_tools_as is set without mcp_server. "
"Set both or neither."
)
return None


Expand Down Expand Up @@ -2255,6 +2345,12 @@ def _normalize_variable(v: Any) -> Any:
"destinations",
"is_advanced",
"merge_field_validation",
"mcp_server",
"mcp_server_id",
"run_tools_as",
"run_tools_as_id",
"max_sequential_turns",
"tool_failure_policy",
)
def _step_call_llm(
block: dict[str, Any], auto: AutomationDef, ctx: LiveContext
Expand Down Expand Up @@ -2289,7 +2385,44 @@ def _step_call_llm(
out["is_advanced"] = block["is_advanced"]
if block.get("merge_field_validation"):
out["merge_field_validation"] = block["merge_field_validation"]
return {k: v for k, v in out.items() if v is not None}
out = {k: v for k, v in out.items() if v is not None}
# Always sent, null included: a PUT that omits both ids keeps the server
# and clears the account, a pairing the write itself rejects.
out["mcp_server_id"] = _tool_ref_uuid(
"mcp_server",
block.get("mcp_server_id"),
block.get("mcp_server"),
ctx.mcp_server_uuid,
)
out["run_tools_as_id"] = _tool_ref_uuid(
"run_tools_as",
block.get("run_tools_as_id"),
block.get("run_tools_as"),
ctx.service_account_uuid,
)
turns = block.get("max_sequential_turns")
out["max_sequential_turns"] = 25 if turns is None else turns
out["tool_failure_policy"] = block.get("tool_failure_policy") or "never_fail"
return out


def _tool_ref_uuid(
key: str, id_value: Any, value: Any, resolve: Callable[[str], str]
) -> str | None:
"""A call_llm tool reference as the bare UUID the wire takes. A read's
expanded object yields its id and a UUID passes through, so translate
never reaches ``resolve``; any other string is a name to look up."""
resolved: set[str] = set()
for v in (id_value, value):
if isinstance(v, dict):
v = v.get("id")
elif v is not None and not is_uuid(v):
v = resolve(v)
if v is not None:
resolved.add(v)
if len(resolved) > 1:
raise PlanError(f"set {key} or {key}_id, not both")
return next(iter(resolved), None)


def _normalize_destinations(destinations: list[dict[str, Any]]) -> list[dict[str, Any]]:
Expand Down
Loading
Loading