8000 feat(workflows): an event scope for automations, with action and decide nodes by adeelahmad · Pull Request #436 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
43 changes: 43 additions & 0 deletions docs/automations.md
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ merged is called built.
| The timeline API (`GET /api/v1/activities`), the MCP tools `timeline` and `event_kinds`, the assistant's `timeline` | built |
| Review marks on the timeline (reviewed, important, hidden), their filters and `mark_activities` ([Timeline](timeline.md)) | built |
| The shapes of triggers, joins, an If, policy and intents, checked (`app/domain/automation_spec.py`) | built |
| The event workflow scope: what an automation does, drawn with action nodes and tried on an activity (`app/domain/automation_nodes.py`) | built |
| The decide node (the decision model picks a branch) | planned |
| The action registry with risk from each action's effect, and one action, `log_line` (`app/domain/actions.py`) | built |
| Telling a person: the `notify` action and `channels.tell` (their own chat, a notification target, a Matterbridge room) | built |
| The model and the agent: the `ask_model`, `call_tool` and `ask_agent` actions, and prompts with outside text marked | built |
Expand Down Expand Up @@ -320,6 +322,45 @@ titles, for the variable picker):
| `vars` | the automation's and its namespace's saved variables |
| `steps` | what each node passed on so far, by node id, such as `steps.n3` |

### The event workflow scope

Built: what an automation does is a workflow of scope `event` (`app/domain/automation_nodes.py`), saved and
versioned like any workflow (`POST /api/v1/workflows` with `"scope": "event"`). Its `input` node passes on the
[run context](#run-context), and between the primitives (condition, switch, set, merge, loops...) it has two nodes of
its own:

- **Action** (`action`, configured `{action, params, delay?, when?}`): one action from the registry. When it runs,
it fills its params in from the run context (`"text": "New: {{ event.object.name }}"`; a param that is exactly one
`{{ }}` expression keeps its type, so a list stays a list; the action's defaults are filled in first, so a default
that is a template, like `ask_agent`'s `about`, is filled in too; a prompt param is rendered with outside text
marked, as [The model and the agent](#the-model-and-the-agent) says), works out its effect and risk and makes an
[intent](#actions-and-intents). A routine intent is carried out there and then: what it gives back passes on along
`out` and goes in `steps.<node id>` for later nodes. One that asks first waits for the person's pick, and one with
a `delay` (like `30m`) or a time (`when`, a time or a template giving one) waits for its time: such a node passes
nothing on, so the nodes after it are skipped and the run's log says where it stopped and why. An action that
fails is marked failed on its node, and the rest of the graph goes on.
- **Decide** (`decide`, configured `{question, options, input?, min_confidence?}`): the decision model picks one of
its options, with an output port per option and `unsure`. Planned with the decision model; until then a decide node
is refused when the workflow is saved, as coming soon. Use a condition or switch node.

Checked when it is saved, on every path (create, a new version, a body):

- It needs an action node, each action is one the registry has (`GET /api/v1/automations/actions` will list them)
with params that fit its schema, and each template in its params is valid.
- **The leaf rule:** an action that reaches outside Lens or other people (effect `out`, worked out from its params)
or that waits has nothing connected after it, as it can't pass anything on while it waits for a yes or its time.
- Waiting before an action (`delay`, `when`) is refused as coming soon until timers are built.

Its automation runs it: an event workflow isn't run on an asset, as a pipeline step or as a routine's action, and the
canvas's list of workflows leaves it out (`GET /api/v1/workflows?scope=event` lists them).

**Try it** with `POST /api/v1/workflows/test` (admins): `{"scope": "event", "graph": ..., "kind": "asset.added"}`
runs it on that kind's sample, and `"activity": "<id>"` on a real activity you can see. Nothing is kept and nothing is
sent: each action says what it would do (`would write “New: Call with the school” to the run log`), and `intents`
lists each one with its params, effect, risk, status (`done`, `waiting`, `scheduled` or `failed`) and what it gave
back. Text from outside Lens (`lens.untrusted` on the activity, or what a node passed on when its output says
`untrusted`, like a web page's answer) that reaches an action's params marks its intent `untrusted`.

### Actions and intents

Built: the registry (`app/domain/actions.py`). An action is registered with `actions.register(ActionSpec(name,
Expand Down Expand Up @@ -480,6 +521,8 @@ the agent can draft an event as a note.
channel; web push; private chats through Matterbridge (it can't tell them from rooms); with several people let in,
"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.
- 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
3 changes: 2 additions & 1 deletion docs/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,8 @@ log.

### Organising the graph

A workflow's scope is either assets (above) or the graph. Graph workflows have their own nodes:
A workflow's scope is assets (above), the graph, or an event: an automation's workflow, run by its automation
([The event workflow scope](automations.md#the-event-workflow-scope)). Graph workflows have their own nodes:

- **Candidates** finds pairs of entities that may be one thing, by the same rules as the merge suggestions (same
letters, acronym, spelling, sounds alike, one name inside the other): inside each namespace (merge) or across
Expand Down
41 changes: 33 additions & 8 deletions fastapi_backend/app/api/v1/routes/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

from typing import Literal

from fastapi import APIRouter, HTTPException

from app.api.deps import Acl, AdminWriter, Cfg, CurrentUser, Db, Writer, domain_errors
from app.api.v1.routes.custom_nodes import me
from app.domain import auth, custom_nodes, flow, jobs, store, workflows
from app.domain import auth, custom_nodes, events, flow, jobs, store, workflows
from app.schemas.common import Created, Ok
from app.schemas.pipelines import JobQueued
from app.schemas.templates import VersionSaved
Expand All @@ -25,7 +27,7 @@
router = APIRouter(prefix="/workflows", tags=["workflows"])


DYNAMIC = {"switch": "outputs", "set": "inputs", "group": "both", "custom": "both"}
DYNAMIC = {"switch": "outputs", "set": "inputs", "group": "both", "custom": "both", "decide": "outputs"}


def _node_type(t):
Expand All @@ -40,38 +42,61 @@ def _node_type(t):
input_ports=ins,
dynamic=DYNAMIC.get(t),
primitive=t in flow.PRIMITIVES,
keeps=t in workflows.TERMINAL,
keeps=t in workflows.TERMINAL or t == "action",
)


@router.get("")
def list_workflows(user: CurrentUser, db: Db) -> WorkflowCatalog:
def list_workflows(user: CurrentUser, db: Db, scope: Literal["recording", "graph", "tool", "event"] | None = None) -> WorkflowCatalog:
"""Saved workflows, plus the nodes a workflow can be built from: the primitives, each scope's own nodes, and the
custom nodes you can use."""
custom nodes you can use. Automations' workflows (scope event) are listed only when asked for with scope."""
return WorkflowCatalog(
node_types=[_node_type(t) for t in workflows.NODE_TYPES],
operators=list(workflows.OPS),
entity_types=list(workflows.ENTITY_TYPES),
workflows=workflows.list_workflows(db),
workflows=workflows.list_workflows(db, scope),
custom_nodes=custom_nodes.visible(db, me(user)),
)


@router.post("/test")
def try_workflow(body: WorkflowTryRequest, user: AdminWriter, acl: Acl, db: Db, cfg: Cfg) -> WorkflowTry:
"""Run a graph (saved or not) once without keeping anything: what each node passed on, for the canvas. Nodes
that would save something say what they would save; models are still asked."""
that would save something say what they would save; models are still asked. An automation's workflow (scope
event) runs on an activity (by id) or a kind's sample, and its actions say what they would do (intents)."""
spaces = None
if body.scope == "recording":
if body.recording is None:
raise HTTPException(400, "choose an asset to try it on")
acl.recording(body.recording, "process")
elif body.scope == "event":
return _try_event(body, user, acl, db, cfg)
else:
spaces = [acl.namespace(n) for n in body.namespaces] if body.namespaces else sorted(store.space_names(db))
with domain_errors():
return WorkflowTry(**workflows.try_graph(db, cfg, body.graph.model_dump(), body.scope, body.recording, spaces, user.email))


def _try_event(body: WorkflowTryRequest, user, acl, db, cfg) -> WorkflowTry:
spaces = [acl.namespace(n) for n in body.namespaces] if body.namespaces else None
if body.activity:
a = events.get(db, cfg, body.activity, events.viewer(user.id, acl.readable(), user.admin))
if a is None:
raise HTTPException(404, "no such activity")
elif body.kind:
try:
a = events.kind_info(body.kind)["sample"]
except KeyError:
raise HTTPException(400, f"no kind {body.kind}; GET /api/v1/activities/kinds lists them") from None
if a is None:
raise HTTPException(400, f"{body.kind} has no sample to try it on; give an activity")
else:
raise HTTPException(400, "give an activity (its id) or a kind of activity to try it on")
who = {"id": user.id, "email": user.email, "name": user.name, "admin": user.admin}
with domain_errors():
return WorkflowTry(**workflows.try_graph(db, cfg, body.graph.model_dump(), "event", spaces=spaces, user=who, activity=a))


@router.post("")
def create_workflow(body: WorkflowCreate, user: AdminWriter, db: Db) -> Created:
with domain_errors():
Expand Down Expand Up @@ -117,7 +142,7 @@ def run_workflow(wid: int, body: WorkflowRunRequest, user: Writer, acl: Acl, db:
except KeyError:
raise HTTPException(400, "no such workflow") from None
if w["scope"] != "recording":
raise HTTPException(400, "this workflow organises the graph; a routine runs it")
raise HTTPException(400, f"this workflow {workflows.RUN_BY.get(w['scope'], 'runs elsewhere')}")
return JobQueued(
job=jobs.add_steps(db, body.recording, [{"type": "workflow", "workflow": wid, "version": w["version"]}], by=user.email)
)
Loading
Loading
0