8000 feat(automations): the variable picker's catalog and a template's preview for a draft automation by adeelahmad · Pull Request #461 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
18 changes: 18 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,24 @@ curl -X PUT -H "Authorization: Bearer $LENS_TOKEN" -H "Content-Type: application
-d '{"vars": {"alerts": 0, "last_alert": null}}' https://lens.example/api/v1/automations/7/vars
```

```
POST /api/v1/automations/variables
POST /api/v1/automations/preview
```

The variable picker and a template's preview for an automation being written
([The variable picker and preview](automations.md#the-variable-picker-and-preview)). Both take `draft`, the automation
as written so far (`{triggers, join?, workflow?, spaces?, id?, name?}`, `workflow` an id or a graph). `variables`
(`{draft, node?}`) answers `{"variables": [{path, type, label, example, group, untrusted}]}`. `preview` (`{template,
draft, activity?, trigger?, node?, prompt?}`) answers `{text, value, prompt, rules, system, error, line, activity}`;
a template that fails is a 200 with `error` and `line`. 400 for a draft that doesn't check, 404 for an activity, a
namespace or an automation you can't see.

```
curl -H "Authorization: Bearer $LENS_TOKEN" -H "Content-Type: application/json" https://lens.example/api/v1/automations/preview \
-d '{"template": "{{ events.mail.object.subject }}", "draft": {"triggers": [{"as": "mail", "on": "event", "kinds": ["email.received"]}]}}'
```

## vaults

```
Expand Down
42 changes: 39 additions & 3 deletions docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ merged is called built.
| The model and the agent: the `ask_model`, `call_tool` and `ask_agent` actions, and prompts with outside text marked | built |
| Templates in params and prompts: the run context, one expression keeping its type, errors that name the nearest variable, saved prompt templates, filters for times, text and addresses ([Variables and templates](#variables-and-templates)) | built |
| Saved variables: `vars` in the run context, the `set_variable` action, the API and MCP tools ([Saved variables](#saved-variables)) | built |
| The variable picker's catalog and a preview of what a template gives | planned |
| The variable picker's catalog and a preview of what a template gives, through the API and MCP ([The variable picker and preview](#the-variable-picker-and-preview)) | built |
| Calling a web address: the `http_request` action, with credentials in headers kept sealed | built |
| Running Lens work: the `run_workflow`, `run_pipeline` and `run_routine` actions | built |
| Changing assets and writing notes: the `set_field`, `add_to_collection` and `write_note` actions | built |
Expand Down Expand Up @@ -877,6 +877,41 @@ variables stay as written. People change them with
`GET`/`PUT /api/v1/automations/{id}/vars` and `/api/v1/namespaces/{name}/automation-vars` ([API](api.md#automations)),
and agents with the `automation_vars` and `set_automation_vars` MCP tools.

### The variable picker and preview

Both work on an automation as it is being written (a draft: `{triggers, join?, workflow?, spaces?, id?, name?}`,
with `workflow` a saved workflow's id or a graph, which may be unfinished), so the builder can help before anything
is saved:

```python
variables.catalog(db, cfg, draft, node=None, user=None) # [{path, type, label, example, group, untrusted}]
variables.preview(db, cfg, template, draft, user, activity=None, trigger=None, node=None, as_prompt=False)
```

`POST /api/v1/automations/variables {draft, node?}` lists what a template there can read, for the picker that opens
on `{{`:

- the run context's keys and their fields (`user.email`, `namespace.name`, `now`...);
- each trigger's activity, `events.<as>.<field>`, from its kind's schema (a schedule's or an `at`'s is the clock's
`clock.tick`, a webhook's `webhook.received`); `event.<field>` for the fields all the triggers' kinds share; a
batch's activities as a list (`events.mail.0.object.subject`);
- the saved variables there are (the automation's own when the draft has its `id`, and its namespaces');
- with `node`, what the action and decide nodes before it pass on, `steps.<id>.<field>`, from each action's output
schema (an `ask_model` with a `json_schema` gives the object it describes).

Each has its type, a label from the schema's titles, an example (from the kind's sample, or the variable's value),
a `group` for the picker's headings and `untrusted` for text from outside Lens, which prompts mark as data.

`POST /api/v1/automations/preview {template, draft, activity?, trigger?, node?, prompt?}` shows what a template gives
before it is saved: against a real activity by id (404 for one you can't see) or the kind's sample of the trigger
that fires (`trigger`, by its `as`; default the first). A param's `value` keeps its type and `text` is it written
out; with `prompt` (or a saved prompt template, `{"template": 5}`) the text is what the model will see, outside text
marked, with `rules`. The steps before `node` are stand-ins named by their path (`[steps.ask.trip]`), as a preview
runs no node; a run's real values come from a test run. A template that fails answers with `error` and `line`, like
`text: events.mail.object.subjct is not defined; did you mean subject?`; a draft that doesn't check answers 400.
Anyone signed in may use both, with their own namespaces (none named: theirs) and only an automation they may change.
Agents have the `automation_variables` and `preview_template` MCP tools.

## Refine later

- More operators: `in`, `matches` (bounded regular expressions), `between`, times against now, `changed`.
Expand Down Expand Up @@ -908,13 +943,14 @@ and agents with the `automation_vars` and `set_automation_vars` MCP tools.
"me" is whoever last wrote privately, until each chat user has a Lens account of their own; telling which links in a
message the person's template wrote, so they stay when the run read outside text.
- The canvas draws and edits event workflows (today they are saved and tried through the API).
- An action's outputs in the variable picker, typed from its `output` schema.
- Variables and templates: secrets as variables (sealed, never rendered into prompts); typed variable declarations
with defaults per automation; a time zone of each person's own, rather than the triggers'; wrapping outside text
the same way in `tool_nodes.ask_model` (pipeline workflows) and extensions' prompt tools; variables in the
Settings page of an automation (the studio, M11); an activity when a person changes a variable; marking a variable
from outside text only when the value itself came from it, not whenever the run read some; removing an
automation's variables when it is deleted (M6).
automation's variables when it is deleted (M6); a preview that runs the nodes before the template's (dry) rather
than standing in for their outputs; the picker listing a saved prompt template's output schema for the node that
uses it; matching on variables in triggers and the If (M5).
- The model and the agent: per-node model picks in the studio; streaming an agent's steps into the run's log as they
happen; a namespace assistant's memory for automations (`remember` is left out today); approving an agent's
proposals from the chat app it told you in; count the automation's own budget once budgets take `automation:<id>`.
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ For an app that speaks OpenAI's API rather than MCP, the namespace can also answ
| `event_kinds` | the kinds of activity there are: label, source, parent and children, a schema with each field's title, the fields holding outside text, and a sample |
| `automation_vars` | the values automations keep between runs ([Saved variables](automations.md#saved-variables)): an automation's own or a namespace's, each with who set it last and whether it came from outside text. Needs access to the automation or the namespace |
| `set_automation_vars` | set or clear an automation's or a namespace's variables (`changes`, a JSON object; null clears one). Needs a write-scope token and the right to change the automation, or editor access to the namespace |
| `automation_variables` | what a drafted automation's templates can read ([The variable picker and preview](automations.md#the-variable-picker-and-preview)): the run context, each trigger's activity fields, the saved variables and what the nodes before a node pass on, with types, labels, examples and which are outside text |
| `preview_template` | what a template in a drafted automation gives before it's saved, against an activity or the trigger kind's sample; a prompt shows outside text marked as the model sees it; an error names the missing variable and its nearest name |
| `file_storage` | where Lens keeps its own files (notes' images and attachments) and the connections it could use; `check` tests it. Admins only |
| `set_file_storage` | keep new files on this machine or a storage connection (folder, rclone crypt); checked before it's saved. Admins only, with a write-scope token |
| `install_settings` | whether other devices on the network reach Lens and its edition (full or lean), whether the machine's upgrade agent can change them, and the install line to run when it can't. Admins only |
Expand Down
53 changes: 53 additions & 0 deletions fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from app.domain import speakers as spk
from app.domain import topics as topicmod
from app.domain.store import DB
from app.schemas.automation_vars import TemplatePreviewIn
from app.schemas.entities import EntityHide, EntityList, EntityRename, EntityRetype, EntityUpdate
from app.schemas.notebook import NotePage
from app.schemas.search import SearchResults
Expand Down Expand Up @@ -2226,3 +2227,55 @@ def set_automation_vars(ctx: Context, automation: int | None, namespace: str | N
target = f"automation:{scope['automation']}" if "automation" in scope else f"space:{scope['space']}"
auth.audit(ctx.db, ctx.user.as_audit(), "automation.vars", target, {"names": sorted(values), "via": "mcp"})
return {"vars": rows}


def _draft(text: str) -> dict[str, Any]:
try:
draft = json.loads(text)
except ValueError:
raise ToolError('draft is the automation as a JSON object, like {"triggers": [{"as": "mail", "on": "event", ...}]}') from None
if not isinstance(draft, dict):
raise ToolError("draft is the automation as a JSON object")
return draft


@tool(
"automation_variables",
"Variables an automation can use",
"What a drafted automation's templates can read, to write its params and prompts: the run context (now, user, "
"namespace...), each trigger's activity fields (events.<as>.object.subject), the saved variables and, with node, "
"what the nodes before it pass on (steps.<id>); each with its type, label, an example and whether it is text from "
"outside Lens. draft is the automation as JSON: {triggers, join?, workflow? (an id or a graph), spaces?, id?}.",
Arg("draft", "string", "the automation as a JSON object", required=True, max_length=200_000),
Arg("node", "string", "a node of its workflow, to list what the nodes before it pass on", max_length=100),
)
def automation_variables(ctx: Context, draft: str, node: str | None) -> dict[str, Any]:
vetted = var_routes.vetted(ctx.db, ctx.user, ctx.acl, _draft(draft))
return {"variables": variables.catalog(ctx.db, ctx.cfg, vetted, node, var_routes.person(ctx.user))}


@tool(
"preview_template",
"Preview a template",
"What a template in a drafted automation gives before it is saved: rendered against an activity (its id) or the "
"trigger kind's sample. With prompt true it renders as a prompt, with outside text marked as the model will see "
"it. A template that fails gives error, naming the missing variable and its nearest name, and line. template is "
'the param\'s text with {{ }}, a JSON list or object of them, or a saved prompt template {"template": 5}.',
Arg("template", "string", "the template", required=True, max_length=200_000),
Arg("draft", "string", "the automation as a JSON object, as for automation_variables", required=True, max_length=200_000),
Arg("activity", "string", "an activity's id to render against (default: the trigger kind's sample)", max_length=200),
Arg("trigger", "string", "which trigger fired, by its name (as); default the first", max_length=40),
Arg("node", "string", "the node the template is in: the steps before it are stand-ins", max_length=100),
Arg("prompt", "boolean", "render it as a prompt", default=False),
)
def preview_template(
ctx: Context, template: str, draft: str, activity: str | None, trigger: str | None, node: str | None, prompt: bool
) -> dict[str, Any]:
value: Any = template
if template.lstrip()[:1] in ("{", "["):
try:
value = json.loads(template) # a list or object of templates, or a saved prompt template
except ValueError:
pass # text that starts with {{ or {%
body = TemplatePreviewIn(template=value, draft=_draft(draft), activity=activity, trigger=trigger, node=node, prompt=prompt)
return var_routes.preview_for(ctx.db, ctx.cfg, ctx.user, ctx.acl, body).model_dump(exclude_none=True)
79 changes: 72 additions & 7 deletions fastapi_backend/app/api/v1/routes/automation_vars.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
"""Automations' saved variables (docs/automations.md, Saved variables): what an automation keeps between runs, read
as vars.<name> in its templates, and what its namespace's automations share. An automation's variables are for whoever
may change it (admins, or people who can edit every namespace it works in); a namespace's are read by its readers and
changed by its editors. Automation ids use the int convertor, so static paths under /automations never collide."""
"""Automations' variables (docs/automations.md, Variables and templates). Saved variables: what an automation keeps
between runs, read as vars.<name> in its templates, and what its namespace's automations share. An automation's
variables are for whoever may change it (admins, or people who can edit every namespace it works in); a namespace's
are read by its readers and changed by its editors. The variable picker's catalog and a template's preview for a
draft automation, against the kind's sample or an activity the person may see. Automation ids use the int
convertor, so static paths under /automations never collide."""

from __future__ import annotations

from typing import Any

from fastapi import APIRouter, HTTPException

from app.api.deps import Acl, CurrentUser, Db, Principal, Writer, domain_errors
from app.domain import auth, store, variables
from app.schemas.automation_vars import AutomationVar, AutomationVars, AutomationVarsIn
from app.api.deps import Acl, Cfg, CurrentUser, Db, Principal, Writer, domain_errors
from app.domain import auth, events, store, variables
from app.schemas.automation_vars import (
AutomationVar,
AutomationVars,
AutomationVarsIn,
TemplatePreviewIn,
TemplatePreviewOut,
VariableCatalog,
VariableCatalogIn,
VariableEntry,
)

router = APIRouter(tags=["automations"])

Expand Down Expand Up @@ -58,3 +69,57 @@ def put_namespace_vars(name: str, body: AutomationVarsIn, user: Writer, acl: Acl
rows = variables.put_vars(db, body.vars, space=sid, by=user.email)
auth.audit(db, user.as_audit(), "automation.vars", f"space:{sid}", {"names": sorted(body.vars)})
return listing(rows)


def vetted(db: Any, user: Principal, acl: Any, draft: dict[str, Any]) -> dict[str, Any]:
"""The draft as this person may preview it: its id only if they may change that automation (404 otherwise), and
its namespaces only ones they read (404 for another; none named: theirs, or every one for an admin)."""
out = dict(draft)
if out.get("id") is not None:
if not isinstance(out["id"], int) or isinstance(out["id"], bool):
raise HTTPException(400, "the draft's id is an automation's id")
automation(db, user, acl, out["id"])
readable = acl.readable()
spaces = out.get("spaces")
if spaces is None:
out["spaces"] = None if readable is None else sorted(readable)
elif not isinstance(spaces, list) or not all(isinstance(s, int) and not isinstance(s, bool) for s in spaces):
raise HTTPException(400, "the draft's spaces are namespace ids")
elif readable is not None and any(s not in readable for s in spaces):
raise HTTPException(404, "no such namespace")
return out


def person(user: Principal) -> dict[str, Any]:
return {"id": user.id, "name": user.name, "email": user.email}


def preview_for(db: Any, cfg: Any, user: Principal, acl: Any, body: TemplatePreviewIn) -> TemplatePreviewOut:
"""The preview of body's template, for the API and the MCP tool."""
draft = vetted(db, user, acl, body.draft)
act = None
if body.activity:
act = events.get(db, cfg, body.activity, events.viewer(user.id, acl.readable(), user.admin))
if act is None:
raise HTTPException(404, "no such activity")
with domain_errors():
got = variables.preview(db, cfg, body.template, draft, person(user), act, body.trigger, body.node, body.prompt)
return TemplatePreviewOut(**got)


@router.post("/automations/variables")
def variable_catalog(body: VariableCatalogIn, user: CurrentUser, acl: Acl, db: Db, cfg: Cfg) -> VariableCatalog:
"""What a draft automation's templates can read, for the variable picker: the run context, each trigger's activity
fields, the saved variables and, with node, what the nodes before it pass on; with types, labels and examples."""
draft = vetted(db, user, acl, body.draft)
with domain_errors():
rows = variables.catalog(db, cfg, draft, body.node, person(user))
return VariableCatalog(variables=[VariableEntry(**r) for r in rows])


@router.post("/automations/preview")
def preview_template(body: TemplatePreviewIn, user: CurrentUser, acl: Acl, db: Db, cfg: Cfg) -> TemplatePreviewOut:
"""What a template gives for a draft automation before it is saved, against an activity (by id; 404 for one you
can't see) or the trigger kind's sample. A prompt shows outside text marked as the model sees it. A template that
fails answers 200 with error and line; a draft that doesn't check answers 400."""
return preview_for(db, cfg, user, acl, body)
Loading
Loading
0