urirun-reasoner 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- urirun_reasoner-0.1.0/PKG-INFO +49 -0
- urirun_reasoner-0.1.0/README.md +40 -0
- urirun_reasoner-0.1.0/pyproject.toml +19 -0
- urirun_reasoner-0.1.0/setup.cfg +4 -0
- urirun_reasoner-0.1.0/tests/test_reasoner.py +119 -0
- urirun_reasoner-0.1.0/urirun_reasoner/__init__.py +26 -0
- urirun_reasoner-0.1.0/urirun_reasoner/capabilities.py +83 -0
- urirun_reasoner-0.1.0/urirun_reasoner/cli.py +25 -0
- urirun_reasoner-0.1.0/urirun_reasoner/gates.py +64 -0
- urirun_reasoner-0.1.0/urirun_reasoner/generation_policy.py +57 -0
- urirun_reasoner-0.1.0/urirun_reasoner/generator.py +149 -0
- urirun_reasoner-0.1.0/urirun_reasoner/intent.py +40 -0
- urirun_reasoner-0.1.0/urirun_reasoner/needs.py +60 -0
- urirun_reasoner-0.1.0/urirun_reasoner/planner.py +119 -0
- urirun_reasoner-0.1.0/urirun_reasoner.egg-info/PKG-INFO +49 -0
- urirun_reasoner-0.1.0/urirun_reasoner.egg-info/SOURCES.txt +17 -0
- urirun_reasoner-0.1.0/urirun_reasoner.egg-info/dependency_links.txt +1 -0
- urirun_reasoner-0.1.0/urirun_reasoner.egg-info/entry_points.txt +2 -0
- urirun_reasoner-0.1.0/urirun_reasoner.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urirun-reasoner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Intent → need → capability → URI plan, headless-first. Model the goal not the app: a missing GUI editor is one blocked method, not the end. Deterministic core, LLM only as fallback.
|
|
5
|
+
Author: Tom Sapletta
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# urirun-reasoner
|
|
11
|
+
|
|
12
|
+
Intent → need → capability → URI plan, **headless-first**. Model the GOAL, not the app.
|
|
13
|
+
|
|
14
|
+
A missing GUI editor is one blocked method in a graph — not the end of the road. Asked to
|
|
15
|
+
"test office on lenovo", the reasoner does not stop at "no LibreOffice"; it reframes to
|
|
16
|
+
"produce a document artifact and verify it exists" and resolves it through the cheapest
|
|
17
|
+
feasible URI path.
|
|
18
|
+
|
|
19
|
+
## The resolution ladder (kernel decides, cheapest/safest first)
|
|
20
|
+
|
|
21
|
+
1. **direct** — a capability is already served on the node (headless)
|
|
22
|
+
2. **host_fallback** — a headless capability runs host-side + sync (no node change)
|
|
23
|
+
3. **install_then_run** — a connector provides it and the node can manage (install → rebuild → restart → smoke → use)
|
|
24
|
+
4. **gui** — a GUI capability, only as a last resort
|
|
25
|
+
5. **blocked** — nothing feasible → honest, with what was tried
|
|
26
|
+
|
|
27
|
+
GUI is never chosen when a headless path exists; connectors are never installed blindly.
|
|
28
|
+
|
|
29
|
+
## Layers
|
|
30
|
+
|
|
31
|
+
| module | role |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `needs.py` | `NeedSpec` — the intent contract (goal, required outputs, preferred/acceptable capabilities, requires_gui) + a catalog |
|
|
34
|
+
| `capabilities.py` | `CapabilityMap` — capability → URI route templates + connectors (the layer between language and URIs) |
|
|
35
|
+
| `intent.py` | prompt → NeedSpec (rules first; LLM only names the intent, never invents URIs) |
|
|
36
|
+
| `planner.py` | the deterministic ladder + `resolve(need, ctx)` with a postcondition |
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from urirun_reasoner import Context, resolve_prompt
|
|
42
|
+
ctx = Context(node="lenovo", node_served=[...], host_served=["fs://host/file/command/write"],
|
|
43
|
+
installable={"sheet"}, can_manage=True)
|
|
44
|
+
plan = resolve_prompt("przetestuj biuro na lenovo", ctx)
|
|
45
|
+
# → {"strategy": "host_fallback", "steps": [...], "postcondition": {...}}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pairs with `urirun-fleet` (node readiness + execution) and the dispatch `_meta` provenance
|
|
49
|
+
(so the reasoner knows what is actually served). Part of the ifURI solution · Apache-2.0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# urirun-reasoner
|
|
2
|
+
|
|
3
|
+
Intent → need → capability → URI plan, **headless-first**. Model the GOAL, not the app.
|
|
4
|
+
|
|
5
|
+
A missing GUI editor is one blocked method in a graph — not the end of the road. Asked to
|
|
6
|
+
"test office on lenovo", the reasoner does not stop at "no LibreOffice"; it reframes to
|
|
7
|
+
"produce a document artifact and verify it exists" and resolves it through the cheapest
|
|
8
|
+
feasible URI path.
|
|
9
|
+
|
|
10
|
+
## The resolution ladder (kernel decides, cheapest/safest first)
|
|
11
|
+
|
|
12
|
+
1. **direct** — a capability is already served on the node (headless)
|
|
13
|
+
2. **host_fallback** — a headless capability runs host-side + sync (no node change)
|
|
14
|
+
3. **install_then_run** — a connector provides it and the node can manage (install → rebuild → restart → smoke → use)
|
|
15
|
+
4. **gui** — a GUI capability, only as a last resort
|
|
16
|
+
5. **blocked** — nothing feasible → honest, with what was tried
|
|
17
|
+
|
|
18
|
+
GUI is never chosen when a headless path exists; connectors are never installed blindly.
|
|
19
|
+
|
|
20
|
+
## Layers
|
|
21
|
+
|
|
22
|
+
| module | role |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `needs.py` | `NeedSpec` — the intent contract (goal, required outputs, preferred/acceptable capabilities, requires_gui) + a catalog |
|
|
25
|
+
| `capabilities.py` | `CapabilityMap` — capability → URI route templates + connectors (the layer between language and URIs) |
|
|
26
|
+
| `intent.py` | prompt → NeedSpec (rules first; LLM only names the intent, never invents URIs) |
|
|
27
|
+
| `planner.py` | the deterministic ladder + `resolve(need, ctx)` with a postcondition |
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from urirun_reasoner import Context, resolve_prompt
|
|
33
|
+
ctx = Context(node="lenovo", node_served=[...], host_served=["fs://host/file/command/write"],
|
|
34
|
+
installable={"sheet"}, can_manage=True)
|
|
35
|
+
plan = resolve_prompt("przetestuj biuro na lenovo", ctx)
|
|
36
|
+
# → {"strategy": "host_fallback", "steps": [...], "postcondition": {...}}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Pairs with `urirun-fleet` (node readiness + execution) and the dispatch `_meta` provenance
|
|
40
|
+
(so the reasoner knows what is actually served). Part of the ifURI solution · Apache-2.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "urirun-reasoner"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Intent → need → capability → URI plan, headless-first. Model the goal not the app: a missing GUI editor is one blocked method, not the end. Deterministic core, LLM only as fallback."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Tom Sapletta" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
urirun-reasoner = "urirun_reasoner.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
include = ["urirun_reasoner*"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""The reasoner contract: model the goal not the app, and walk the ladder cheapest-first.
|
|
3
|
+
GUI is the LAST resort; a missing editor never ends the process."""
|
|
4
|
+
from urirun_reasoner import Context, intent, needs, planner, resolve, resolve_prompt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_intent_maps_office_words_to_document_need():
|
|
8
|
+
assert intent.classify("napisz artykuł w edytorze tekstu").id == "office.document.create"
|
|
9
|
+
assert intent.classify("przetestuj biuro na lenovo").id == "office.document.create"
|
|
10
|
+
assert intent.classify("stwórz arkusz z danymi").id == "office.spreadsheet.create"
|
|
11
|
+
assert intent.classify("zupełnie coś innego") is None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_direct_when_capability_already_served():
|
|
15
|
+
ctx = Context(node="laptop", node_served=["sheet://laptop/workbook/command/write"])
|
|
16
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
17
|
+
assert p["resolved"] and p["strategy"] == "direct" and p["capability"] == "sheet.write"
|
|
18
|
+
assert p["steps"][0]["uri"] == "sheet://laptop/workbook/command/write"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_host_fallback_before_install_when_node_lacks_it():
|
|
22
|
+
# node serves nothing useful; host can write files → host_fallback beats installing
|
|
23
|
+
ctx = Context(node="laptop", node_served=["kvm://laptop/screen/query/capture"],
|
|
24
|
+
host_served=["fs://host/file/command/write"],
|
|
25
|
+
installable={"sheet"}, can_manage=True)
|
|
26
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
27
|
+
assert p["strategy"] == "host_fallback" and p["capability"] == "markdown.write"
|
|
28
|
+
assert p["steps"][0]["uri"] == "fs://host/file/command/write"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_install_when_no_direct_and_no_host_fallback():
|
|
32
|
+
# nothing served anywhere, but a connector is installable and the node can manage
|
|
33
|
+
ctx = Context(node="laptop", node_served=[], host_served=[],
|
|
34
|
+
installable={"sheet"}, can_manage=True)
|
|
35
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
36
|
+
assert p["strategy"] == "install_then_run" and p["connector"] == "sheet"
|
|
37
|
+
uris = [s["uri"] for s in p["steps"]]
|
|
38
|
+
assert uris[0] == "node://laptop/connector/command/install"
|
|
39
|
+
assert "node://laptop/runtime/command/restart" in uris # drop stale workers
|
|
40
|
+
assert uris[-1].startswith("sheet://laptop/") # then actually use it
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_gui_is_last_resort_only_when_allowed_and_nothing_else():
|
|
44
|
+
ctx = Context(node="laptop", node_served=["browser://laptop/page/command/navigate"],
|
|
45
|
+
host_served=[], installable=set(), can_manage=False, allow_gui=True)
|
|
46
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
47
|
+
assert p["strategy"] == "gui" and p["capability"] == "browser.document_edit"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_gui_not_chosen_when_headless_exists():
|
|
51
|
+
# both a served browser (gui) AND a served sheet (headless) → headless wins
|
|
52
|
+
ctx = Context(node="laptop",
|
|
53
|
+
node_served=["browser://laptop/page/command/navigate",
|
|
54
|
+
"sheet://laptop/workbook/command/write"],
|
|
55
|
+
allow_gui=True)
|
|
56
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
57
|
+
assert p["strategy"] == "direct" and p["capability"] == "sheet.write"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_blocked_is_honest_with_what_was_tried():
|
|
61
|
+
ctx = Context(node="laptop", node_served=[], host_served=[], installable=set(),
|
|
62
|
+
can_manage=False, allow_gui=False)
|
|
63
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
64
|
+
assert p["resolved"] is False and set(p["tried"]) >= {"direct", "host_fallback", "install", "gui"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_plan_carries_postcondition():
|
|
68
|
+
ctx = Context(node="laptop", node_served=["sheet://laptop/workbook/command/write"])
|
|
69
|
+
p = planner.resolve(needs.OFFICE_DOCUMENT, ctx)
|
|
70
|
+
assert p["postcondition"] and p["postcondition"]["check"] == "exists"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_resolve_prompt_office_on_lenovo_reframes_to_headless():
|
|
74
|
+
# THE flagship case: "test office on lenovo" with no GUI → headless host fallback
|
|
75
|
+
ctx = Context(node="lenovo", node_served=["kvm://lenovo/screen/query/capture"],
|
|
76
|
+
host_served=["fs://host/file/command/write"], installable={"sheet"},
|
|
77
|
+
can_manage=True, allow_gui=True)
|
|
78
|
+
p = resolve_prompt("przetestuj biuro na lenovo", ctx)
|
|
79
|
+
assert p["resolved"] and p["strategy"] == "host_fallback"
|
|
80
|
+
assert p["need"] == "office.document.create" and not p["strategy"] == "gui"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- gates: risky actions are never ungated -------------------------------------
|
|
84
|
+
def test_gate_classifies_risky_verbs():
|
|
85
|
+
from urirun_reasoner import gates
|
|
86
|
+
assert gates.gate_for("linkedin://user/post/command/publish")
|
|
87
|
+
assert gates.gate_for("fiverr://user/order/command/pay")
|
|
88
|
+
assert gates.gate_for("email://user/message/command/send")
|
|
89
|
+
assert gates.gate_for("fs://host/file/command/write") is None # autonomous
|
|
90
|
+
assert gates.gate_for("sheet://host/workbook/command/write") is None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_gate_inserts_approval_before_publish():
|
|
94
|
+
from urirun_reasoner import gates
|
|
95
|
+
steps = [{"uri": "llm://host/social/command/write-linkedin-post"},
|
|
96
|
+
{"uri": "linkedin://user/post/command/publish"}]
|
|
97
|
+
out = gates.apply(steps)
|
|
98
|
+
assert out[0]["uri"].endswith("social/command/write-linkedin-post") # autonomous kept
|
|
99
|
+
assert out[1]["uri"].startswith("approval://human/") # gate inserted
|
|
100
|
+
assert out[2]["gate"] == "required"
|
|
101
|
+
assert gates.has_ungated_risk(out) == [] # audit clean
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_audit_catches_bare_payment():
|
|
105
|
+
from urirun_reasoner import gates
|
|
106
|
+
risky = gates.has_ungated_risk([{"uri": "fiverr://user/order/command/pay"}])
|
|
107
|
+
assert risky == ["fiverr://user/order/command/pay"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_generator_scaffolds_a_valid_connector(tmp_path):
|
|
111
|
+
from urirun_reasoner import generator
|
|
112
|
+
spec = {"id": "demo", "scheme": "demo", "summary": "x",
|
|
113
|
+
"handlers": [{"route": "thing/query/list", "params": "", "body": 'return _ok(action="x")'}]}
|
|
114
|
+
r = generator.generate(spec, tmp_path)
|
|
115
|
+
assert r["ok"] and r["routes"] == ["thing/query/list"]
|
|
116
|
+
core = (tmp_path / "urirun-connector-demo" / "urirun_connector_demo" / "core.py").read_text()
|
|
117
|
+
assert 'scheme="demo"' in core and "def thing_query_list" in core
|
|
118
|
+
assert (tmp_path / "urirun-connector-demo" / "pyproject.toml").is_file()
|
|
119
|
+
assert (tmp_path / "urirun-connector-demo" / "tests" / "test_demo.py").is_file()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Author: Tom Sapletta · https://tom.sapletta.com
|
|
2
|
+
# Part of the ifURI solution.
|
|
3
|
+
"""urirun-reasoner — intent → need → capability → URI plan, headless-first.
|
|
4
|
+
|
|
5
|
+
Model the GOAL, not the app. A missing GUI editor is one blocked method in a graph, not
|
|
6
|
+
the end of the road: the reasoner reframes "test office" into "produce a document
|
|
7
|
+
artifact" and resolves it through the cheapest feasible URI path (served route → host
|
|
8
|
+
fallback → install connector → GUI last). Deterministic core; LLM only where rules can't.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from . import capabilities, intent, needs, planner
|
|
13
|
+
from .needs import NeedSpec
|
|
14
|
+
from .planner import Context, resolve
|
|
15
|
+
|
|
16
|
+
__all__ = ["capabilities", "intent", "needs", "planner", "NeedSpec", "Context", "resolve"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_prompt(prompt: str, ctx: "Context", *, llm=None) -> dict:
|
|
20
|
+
"""One call: classify the prompt to a need, then resolve it to a URI plan."""
|
|
21
|
+
need = intent.classify(prompt, llm=llm)
|
|
22
|
+
if not need:
|
|
23
|
+
return {"resolved": False, "reason": f"could not classify intent: {prompt!r}"}
|
|
24
|
+
plan = resolve(need, ctx)
|
|
25
|
+
plan["goal"] = need.goal
|
|
26
|
+
return plan
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""CapabilityMap — the missing layer between a NEED and concrete URIs/connectors.
|
|
3
|
+
|
|
4
|
+
A capability (e.g. ``document.create``) maps to the routes that provide it and the
|
|
5
|
+
connectors that install it. This is what turns "I need to author a document" into
|
|
6
|
+
"call sheet://… , or install urirun-connector-sheet". Route templates use ``{node}``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Capability:
|
|
16
|
+
id: str
|
|
17
|
+
routes: tuple[str, ...] # URI templates ({node} placeholder)
|
|
18
|
+
connectors: tuple[str, ...] = () # connector ids that provide this capability
|
|
19
|
+
gui: bool = False
|
|
20
|
+
host_ok: bool = True # can this run host-side as a fallback?
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MAP: dict[str, Capability] = {
|
|
24
|
+
"document.create": Capability("document.create",
|
|
25
|
+
("document://{node}/document/command/create", "odt://{node}/document/command/create"),
|
|
26
|
+
("document", "office"), host_ok=True),
|
|
27
|
+
"sheet.write": Capability("sheet.write",
|
|
28
|
+
("sheet://{node}/workbook/command/write", "sheet://{node}/rows/command/write",
|
|
29
|
+
"xlsx://{node}/workbook/command/write"),
|
|
30
|
+
("sheet",), host_ok=True),
|
|
31
|
+
"invoice.render": Capability("invoice.render",
|
|
32
|
+
("invoice://{node}/invoice/command/render", "invoice://{node}/document/command/build"),
|
|
33
|
+
("invoice",), host_ok=True),
|
|
34
|
+
"markdown.write": Capability("markdown.write",
|
|
35
|
+
("fs://{node}/file/command/write",), ("fs",), host_ok=True),
|
|
36
|
+
"html.write": Capability("html.write",
|
|
37
|
+
("fs://{node}/file/command/write",), ("fs",), host_ok=True),
|
|
38
|
+
"browser.document_edit": Capability("browser.document_edit",
|
|
39
|
+
("browser://{node}/page/command/navigate", "cdp://{node}/page/command/navigate"),
|
|
40
|
+
("browser", "cdp"), gui=True, host_ok=False),
|
|
41
|
+
"screen.capture": Capability("screen.capture",
|
|
42
|
+
("screen://{node}/screen/query/capture", "kvm://{node}/screen/query/capture",
|
|
43
|
+
"view://{node}/screenshot/query/capture"),
|
|
44
|
+
("kvm",), host_ok=False),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get(cap_id: str) -> Capability | None:
|
|
49
|
+
return MAP.get(cap_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _served(route_tmpl: str, node: str, served: list[str]) -> str | None:
|
|
53
|
+
"""Return the concrete served route matching this template for ``node``, else None."""
|
|
54
|
+
want = route_tmpl.replace("{node}", node)
|
|
55
|
+
tail = want.split("://", 1)[-1]
|
|
56
|
+
for r in served:
|
|
57
|
+
if r == want or fnmatch.fnmatch(r.split("://", 1)[-1], tail) or \
|
|
58
|
+
fnmatch.fnmatch(r.split("://", 1)[-1], route_tmpl.split("://", 1)[-1].replace("{node}", "*")):
|
|
59
|
+
return r
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def served_route(cap_id: str, node: str, served: list[str]) -> str | None:
|
|
64
|
+
"""Is this capability already served on the node? Return the concrete route or None."""
|
|
65
|
+
cap = MAP.get(cap_id)
|
|
66
|
+
if not cap:
|
|
67
|
+
return None
|
|
68
|
+
for tmpl in cap.routes:
|
|
69
|
+
hit = _served(tmpl, node, served)
|
|
70
|
+
if hit:
|
|
71
|
+
return hit
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install_route(cap_id: str, node: str) -> str:
|
|
76
|
+
"""The concrete route to call once the capability's connector is installed."""
|
|
77
|
+
cap = MAP.get(cap_id)
|
|
78
|
+
return cap.routes[0].replace("{node}", node) if cap else ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def providers(cap_id: str) -> tuple[str, ...]:
|
|
82
|
+
cap = MAP.get(cap_id)
|
|
83
|
+
return cap.connectors if cap else ()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""urirun-reasoner CLI: resolve a prompt to a headless-first URI plan."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse, json
|
|
5
|
+
from . import Context, resolve_prompt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None) -> int:
|
|
9
|
+
ap = argparse.ArgumentParser(prog="urirun-reasoner")
|
|
10
|
+
ap.add_argument("prompt")
|
|
11
|
+
ap.add_argument("--node", default="host")
|
|
12
|
+
ap.add_argument("--node-served", nargs="*", default=[])
|
|
13
|
+
ap.add_argument("--host-served", nargs="*", default=[])
|
|
14
|
+
ap.add_argument("--installable", nargs="*", default=[])
|
|
15
|
+
ap.add_argument("--can-manage", action="store_true")
|
|
16
|
+
ap.add_argument("--allow-gui", action="store_true")
|
|
17
|
+
a = ap.parse_args(argv)
|
|
18
|
+
ctx = Context(node=a.node, node_served=a.node_served, host_served=a.host_served,
|
|
19
|
+
installable=set(a.installable), can_manage=a.can_manage, allow_gui=a.allow_gui)
|
|
20
|
+
print(json.dumps(resolve_prompt(a.prompt, ctx), indent=1, default=str))
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""Gates — the hard rules on what the system may NEVER do without a human.
|
|
3
|
+
|
|
4
|
+
A plan step is classified by its URI: publishing, paying, messaging a vendor, ordering,
|
|
5
|
+
or sending private files are ALWAYS human-gated; searching, drafting, packaging,
|
|
6
|
+
comparing, generating a document are autonomous. The planner inserts an
|
|
7
|
+
``approval://human/...`` gate before any gated step, or downgrades it to draft mode.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# (matcher substring, gate reason) — a step whose URI contains the substring is gated
|
|
12
|
+
_GATED: list[tuple[str, str]] = [
|
|
13
|
+
("/command/publish", "public publication requires human approval"),
|
|
14
|
+
("/command/pay", "payment always requires human approval"),
|
|
15
|
+
("/command/schedule", "scheduling a public post requires human approval"),
|
|
16
|
+
("/message/command/send", "sending a message to a vendor requires human approval"),
|
|
17
|
+
("/email/command/send", "sending email requires human approval"),
|
|
18
|
+
("/order/command", "placing/ordering a service requires human approval"),
|
|
19
|
+
("/command/confirm", "confirmation of a paid/committed action requires a human"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# autonomous-safe verbs (never gated) — for documentation / assertion
|
|
23
|
+
AUTONOMOUS = ("query/", "command/write", "command/draft", "command/package",
|
|
24
|
+
"command/render", "command/create", "command/summarize", "command/extract",
|
|
25
|
+
"command/compare", "command/search", "command/build")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def gate_for(uri: str) -> str | None:
|
|
29
|
+
"""Return the human-gate reason for a URI, or None if it is autonomous-safe."""
|
|
30
|
+
u = str(uri)
|
|
31
|
+
for needle, reason in _GATED:
|
|
32
|
+
if needle in u:
|
|
33
|
+
return reason
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply(steps: list[dict], *, node_for_approval: str = "human") -> list[dict]:
|
|
38
|
+
"""Insert an approval:// gate before each gated step; leave autonomous steps as-is.
|
|
39
|
+
|
|
40
|
+
Returns a new step list. A gated step also gets ``gate: "required"`` so an executor
|
|
41
|
+
refuses to run it until the preceding approval is granted (draft-then-approve)."""
|
|
42
|
+
out: list[dict] = []
|
|
43
|
+
for step in steps:
|
|
44
|
+
reason = gate_for(step.get("uri", ""))
|
|
45
|
+
if reason:
|
|
46
|
+
out.append({"uri": f"approval://{node_for_approval}/action/command/review",
|
|
47
|
+
"for": "gate", "reason": reason, "reviews": step.get("uri")})
|
|
48
|
+
out.append({**step, "gate": "required"})
|
|
49
|
+
else:
|
|
50
|
+
out.append(step)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def has_ungated_risk(steps: list[dict]) -> list[str]:
|
|
55
|
+
"""Audit: return URIs of any gated step NOT immediately preceded by an approval.
|
|
56
|
+
A safety net — the plan must never carry a bare publish/pay/send."""
|
|
57
|
+
risky: list[str] = []
|
|
58
|
+
prev_is_gate = False
|
|
59
|
+
for step in steps:
|
|
60
|
+
uri = step.get("uri", "")
|
|
61
|
+
if gate_for(uri) and not prev_is_gate:
|
|
62
|
+
risky.append(uri)
|
|
63
|
+
prev_is_gate = uri.startswith("approval://")
|
|
64
|
+
return risky
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""Generation policy — the rules under which the system may create a NEW connector itself.
|
|
3
|
+
|
|
4
|
+
Autonomy needs guardrails, not permission prompts. A connector spec is admissible only if
|
|
5
|
+
it introduces a NEW scheme (never silently overwrites a served one), every handler routes
|
|
6
|
+
through the urirun envelope, any destructive/network verb (delete/pay/publish/send) is
|
|
7
|
+
marked gated, no secret is baked inline, and the id/scheme are clean slugs. A generated
|
|
8
|
+
connector must pass its own smoke test before it is installed or published. These checks
|
|
9
|
+
are what let the reasoner call ``connector://.../generate`` without asking a human first.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_SLUG = re.compile(r"^[a-z][a-z0-9-]{1,39}$")
|
|
17
|
+
_GATED_VERBS = ("delete", "pay", "publish", "send", "order", "remove", "drop", "wipe", "purge")
|
|
18
|
+
_SECRET_HINT = re.compile(r"(password|api[_-]?key|secret|token)\s*=\s*['\"][^'\"]+['\"]", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check(spec: dict, *, served_schemes: set | None = None) -> dict[str, Any]:
|
|
22
|
+
"""Validate a connector spec against the generation policy. Returns
|
|
23
|
+
{ok, violations, gated_routes}. ``served_schemes`` are schemes already served (must not
|
|
24
|
+
be overwritten)."""
|
|
25
|
+
violations: list[str] = []
|
|
26
|
+
cid = str(spec.get("id", ""))
|
|
27
|
+
scheme = str(spec.get("scheme", cid))
|
|
28
|
+
if not _SLUG.match(cid):
|
|
29
|
+
violations.append(f"id {cid!r} is not a clean slug [a-z][a-z0-9-]{{1,39}}")
|
|
30
|
+
if not _SLUG.match(scheme):
|
|
31
|
+
violations.append(f"scheme {scheme!r} is not a clean slug")
|
|
32
|
+
if served_schemes and scheme in served_schemes:
|
|
33
|
+
violations.append(f"scheme '{scheme}://' is already served — refuse to overwrite (bump/rename instead)")
|
|
34
|
+
|
|
35
|
+
handlers = spec.get("handlers") or []
|
|
36
|
+
if not handlers:
|
|
37
|
+
violations.append("spec has no handlers")
|
|
38
|
+
gated_routes: list[str] = []
|
|
39
|
+
for h in handlers:
|
|
40
|
+
route = str(h.get("route", ""))
|
|
41
|
+
if "/" not in route:
|
|
42
|
+
violations.append(f"handler route {route!r} must look like 'noun/verb/name'")
|
|
43
|
+
body = str(h.get("body", ""))
|
|
44
|
+
if _SECRET_HINT.search(body):
|
|
45
|
+
violations.append(f"handler {route!r} appears to hardcode a secret — use secret:// references")
|
|
46
|
+
# a destructive/network verb must be declared gated so an executor requires approval
|
|
47
|
+
if any(v in route.lower() for v in _GATED_VERBS):
|
|
48
|
+
if not h.get("gated"):
|
|
49
|
+
violations.append(f"handler {route!r} uses a destructive/network verb but is not marked gated")
|
|
50
|
+
gated_routes.append(route)
|
|
51
|
+
|
|
52
|
+
return {"ok": not violations, "violations": violations, "gated_routes": gated_routes,
|
|
53
|
+
"id": cid, "scheme": scheme}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def admissible(spec: dict, *, served_schemes: set | None = None) -> bool:
|
|
57
|
+
return check(spec, served_schemes=served_schemes)["ok"]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""Connector generator — the autonomy that CLOSES the loop instead of waiting for a human.
|
|
3
|
+
|
|
4
|
+
When the reasoner reports a capability blocked because a scheme has no connector, this
|
|
5
|
+
generator scaffolds a complete, installable urirun connector from a spec (id, scheme,
|
|
6
|
+
handlers). Handler bodies come from the spec (deterministic) or an LLM (for novel logic);
|
|
7
|
+
the structure — pyproject, entry point, envelope, manifest, __init__, a smoke test — is
|
|
8
|
+
templated and always valid. The result is a package a node can pip-install.
|
|
9
|
+
|
|
10
|
+
spec = {"id": "document", "scheme": "document", "summary": "...",
|
|
11
|
+
"handlers": [{"route": "document/command/create", "label": "...",
|
|
12
|
+
"params": "path: str = '', text: str = ''", "body": "..."}]}
|
|
13
|
+
generate(spec, dest) -> writes urirun-connector-<id>/…
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_PYPROJECT = '''[build-system]
|
|
21
|
+
requires = ["setuptools>=61"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
|
23
|
+
|
|
24
|
+
[project]
|
|
25
|
+
name = "urirun-connector-{id}"
|
|
26
|
+
version = "0.1.0"
|
|
27
|
+
description = "{summary}"
|
|
28
|
+
readme = "README.md"
|
|
29
|
+
requires-python = ">=3.10"
|
|
30
|
+
license = {{ text = "Apache-2.0" }}
|
|
31
|
+
authors = [{{ name = "Tom Sapletta" }}]
|
|
32
|
+
dependencies = ["urirun>=0.4.14"{extra_deps}]
|
|
33
|
+
|
|
34
|
+
[project.entry-points."urirun.bindings"]
|
|
35
|
+
{id} = "urirun_connector_{mod}.core:urirun_bindings"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
include = ["urirun_connector_{mod}*"]
|
|
39
|
+
'''
|
|
40
|
+
|
|
41
|
+
_CORE_HEADER = '''# Author: Tom Sapletta · Part of the ifURI solution — GENERATED by urirun-reasoner.
|
|
42
|
+
"""urirun-connector-{id} — {summary}
|
|
43
|
+
|
|
44
|
+
Generated autonomously to satisfy a capability the reasoner found blocked (no connector
|
|
45
|
+
served the {scheme}:// scheme). Built to URI_NATIVE_CONNECTOR_CHECKLIST: lazy imports,
|
|
46
|
+
handlers never raise (urirun envelope), queries in-process.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
import urirun
|
|
53
|
+
|
|
54
|
+
CONNECTOR_ID = "{id}"
|
|
55
|
+
conn = urirun.connector(CONNECTOR_ID, scheme="{scheme}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ok(**kw: Any) -> dict[str, Any]:
|
|
59
|
+
return urirun.ok(connector=CONNECTOR_ID, **kw)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fail(msg: str, action: str, **extra: Any) -> dict[str, Any]:
|
|
63
|
+
return urirun.fail(msg, connector=CONNECTOR_ID, action=action, **extra)
|
|
64
|
+
|
|
65
|
+
'''
|
|
66
|
+
|
|
67
|
+
_HANDLER = '''
|
|
68
|
+
@conn.handler("{route}", isolated={isolated},
|
|
69
|
+
meta={{"label": "{label}"}})
|
|
70
|
+
def {fn}({params}) -> dict[str, Any]:
|
|
71
|
+
"""{doc}"""
|
|
72
|
+
try:
|
|
73
|
+
{body}
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
return _fail(str(exc), "{action}")
|
|
76
|
+
|
|
77
|
+
'''
|
|
78
|
+
|
|
79
|
+
_CORE_FOOTER = '''
|
|
80
|
+
def urirun_bindings() -> dict[str, Any]:
|
|
81
|
+
return conn.bindings()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def connector_manifest() -> dict[str, Any]:
|
|
85
|
+
return urirun.load_manifest(__package__) or {"id": CONNECTOR_ID}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: list[str] | None = None) -> int:
|
|
89
|
+
return conn.cli(argv, manifest_prose=urirun.load_manifest(__package__))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
raise SystemExit(main())
|
|
94
|
+
'''
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fn_name(route: str) -> str:
|
|
98
|
+
return route.replace("/", "_").replace("-", "_")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _core(spec: dict) -> str:
|
|
102
|
+
parts = [_CORE_HEADER.format(id=spec["id"], scheme=spec.get("scheme", spec["id"]),
|
|
103
|
+
summary=spec.get("summary", ""))]
|
|
104
|
+
for h in spec.get("handlers", []):
|
|
105
|
+
body = "\n".join(" " + ln for ln in (h.get("body") or 'return _ok(action="todo")').splitlines())
|
|
106
|
+
parts.append(_HANDLER.format(
|
|
107
|
+
route=h["route"], isolated=str(bool(h.get("isolated", "/command/" in h["route"]))),
|
|
108
|
+
label=h.get("label", h["route"]), fn=_fn_name(h["route"]),
|
|
109
|
+
params=h.get("params", ""), doc=h.get("doc", h.get("label", h["route"])),
|
|
110
|
+
body=body, action=h.get("action", spec["id"] + "-" + _fn_name(h["route"]))))
|
|
111
|
+
parts.append(_CORE_FOOTER)
|
|
112
|
+
return "".join(parts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _smoke_test(spec: dict) -> str:
|
|
116
|
+
mod = spec["id"].replace("-", "_")
|
|
117
|
+
lines = ["# GENERATED smoke test — every handler returns a well-formed urirun envelope.",
|
|
118
|
+
f"from urirun_connector_{mod} import core\n\n",
|
|
119
|
+
"def test_bindings_expose_all_routes():",
|
|
120
|
+
f" text = str(core.urirun_bindings())"]
|
|
121
|
+
for h in spec.get("handlers", []):
|
|
122
|
+
lines.append(f' assert {h["route"]!r} in text')
|
|
123
|
+
return "\n".join(lines) + "\n"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def generate(spec: dict, dest: str | Path) -> dict[str, Any]:
|
|
127
|
+
"""Write a complete connector package under ``dest/urirun-connector-<id>/``."""
|
|
128
|
+
cid = spec["id"]
|
|
129
|
+
mod = cid.replace("-", "_")
|
|
130
|
+
root = Path(dest) / f"urirun-connector-{cid}"
|
|
131
|
+
pkg = root / f"urirun_connector_{mod}"
|
|
132
|
+
(pkg / "").mkdir(parents=True, exist_ok=True)
|
|
133
|
+
(root / "tests").mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
extra = "".join(f', "{d}"' for d in spec.get("deps", []))
|
|
136
|
+
(root / "pyproject.toml").write_text(
|
|
137
|
+
_PYPROJECT.format(id=cid, mod=mod, summary=spec.get("summary", ""), extra_deps=extra),
|
|
138
|
+
encoding="utf-8")
|
|
139
|
+
(pkg / "core.py").write_text(_core(spec), encoding="utf-8")
|
|
140
|
+
(pkg / "__init__.py").write_text(
|
|
141
|
+
f"from .core import CONNECTOR_ID, connector_manifest, main, urirun_bindings\n"
|
|
142
|
+
f'__all__ = ["CONNECTOR_ID", "connector_manifest", "main", "urirun_bindings"]\n',
|
|
143
|
+
encoding="utf-8")
|
|
144
|
+
(root / "tests" / f"test_{mod}.py").write_text(_smoke_test(spec), encoding="utf-8")
|
|
145
|
+
(root / "README.md").write_text(
|
|
146
|
+
f"# urirun-connector-{cid}\n\n{spec.get('summary','')}\n\n"
|
|
147
|
+
f"Generated by urirun-reasoner to serve the `{spec.get('scheme', cid)}://` scheme.\n"
|
|
148
|
+
f"Part of the ifURI solution · Apache-2.0\n", encoding="utf-8")
|
|
149
|
+
return {"ok": True, "path": str(root), "routes": [h["route"] for h in spec.get("handlers", [])]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""Intent classification — prompt → NeedSpec. Rules first, LLM only as a fallback.
|
|
3
|
+
|
|
4
|
+
Most needs are recognizable by keyword (deterministic, no model call). An injected LLM
|
|
5
|
+
resolves novel prompts to a known need id; it never invents URIs, only names the intent —
|
|
6
|
+
"LLM proposes the need, the kernel maps it to capabilities and decides the method."
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from . import needs as _needs
|
|
13
|
+
|
|
14
|
+
Completer = Callable[[str], str]
|
|
15
|
+
|
|
16
|
+
# keyword → need id (rule-based, cheap path)
|
|
17
|
+
_RULES: list[tuple[tuple[str, ...], str]] = [
|
|
18
|
+
(("arkusz", "spreadsheet", "xlsx", "excel", "tabela"), "office.spreadsheet.create"),
|
|
19
|
+
(("dokument", "document", "artykuł", "article", "biuro", "office", "notatk", "pismo",
|
|
20
|
+
"edytor", "editor", "napisz", "write", "libreoffice", "onlyoffice", "faktur", "invoice"),
|
|
21
|
+
"office.document.create"),
|
|
22
|
+
(("zrzut", "screenshot", "ekran", "screen capture", "capture"), "desktop.screenshot"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify(prompt: str, llm: Completer | None = None) -> _needs.NeedSpec | None:
|
|
27
|
+
"""Return the NeedSpec for a prompt. Rule-based first; LLM fallback names the need id."""
|
|
28
|
+
low = (prompt or "").lower()
|
|
29
|
+
for keywords, need_id in _RULES:
|
|
30
|
+
if any(k in low for k in keywords):
|
|
31
|
+
return _needs.get(need_id)
|
|
32
|
+
if llm:
|
|
33
|
+
try:
|
|
34
|
+
catalog = ", ".join(_needs.CATALOG)
|
|
35
|
+
reply = llm(f"Map this request to ONE need id from [{catalog}] or 'none'. "
|
|
36
|
+
f"Reply with only the id.\nRequest: {prompt}").strip().strip('"')
|
|
37
|
+
return _needs.get(reply)
|
|
38
|
+
except Exception: # noqa: BLE001 - a bad LLM reply just means 'unclassified'
|
|
39
|
+
return None
|
|
40
|
+
return None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""NeedSpec — the contract of an INTENT, not a URI. Models the goal, not the app.
|
|
3
|
+
|
|
4
|
+
"Test office on lenovo" is not "launch ONLYOFFICE" — it is "produce a document artifact
|
|
5
|
+
and verify it exists". Modeling the need (not the means) is what lets the planner reframe
|
|
6
|
+
away from a missing GUI editor to a headless path. Each need lists the capabilities that
|
|
7
|
+
would satisfy it (preferred → acceptable) and whether a GUI is genuinely required.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class NeedSpec:
|
|
16
|
+
id: str
|
|
17
|
+
goal: str
|
|
18
|
+
required_outputs: tuple[str, ...] = () # artifact/postcondition globs proving success
|
|
19
|
+
preferred_capabilities: tuple[str, ...] = () # best (headless, verifiable) first
|
|
20
|
+
acceptable_capabilities: tuple[str, ...] = () # fallbacks that still satisfy the goal
|
|
21
|
+
forbidden_methods: tuple[str, ...] = ()
|
|
22
|
+
requires_gui: bool = False
|
|
23
|
+
destructive: bool = False
|
|
24
|
+
|
|
25
|
+
def capabilities(self) -> list[str]:
|
|
26
|
+
return list(self.preferred_capabilities) + list(self.acceptable_capabilities)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- the needs catalog -----------------------------------------------------------
|
|
30
|
+
OFFICE_DOCUMENT = NeedSpec(
|
|
31
|
+
id="office.document.create",
|
|
32
|
+
goal="Create a document-like artifact and verify it exists — no GUI editor required.",
|
|
33
|
+
required_outputs=("artifact://*/documents/*", "fs://*/file/query/stat"),
|
|
34
|
+
preferred_capabilities=("document.create", "sheet.write", "invoice.render"),
|
|
35
|
+
acceptable_capabilities=("markdown.write", "html.write", "browser.document_edit"),
|
|
36
|
+
requires_gui=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
SPREADSHEET = NeedSpec(
|
|
40
|
+
id="office.spreadsheet.create",
|
|
41
|
+
goal="Create a spreadsheet artifact and read it back.",
|
|
42
|
+
required_outputs=("artifact://*/documents/*.xlsx", "fs://*/file/query/stat"),
|
|
43
|
+
preferred_capabilities=("sheet.write",),
|
|
44
|
+
acceptable_capabilities=("markdown.write",),
|
|
45
|
+
requires_gui=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
SCREENSHOT = NeedSpec(
|
|
49
|
+
id="desktop.screenshot",
|
|
50
|
+
goal="Capture the screen of a node.",
|
|
51
|
+
required_outputs=("artifact://*/screenshots/*",),
|
|
52
|
+
preferred_capabilities=("screen.capture",),
|
|
53
|
+
requires_gui=False,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
CATALOG: dict[str, NeedSpec] = {n.id: n for n in (OFFICE_DOCUMENT, SPREADSHEET, SCREENSHOT)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get(need_id: str) -> NeedSpec | None:
|
|
60
|
+
return CATALOG.get(need_id)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Author: Tom Sapletta · Part of the ifURI solution.
|
|
2
|
+
"""The deterministic resolution ladder — LLM proposes, the kernel decides.
|
|
3
|
+
|
|
4
|
+
Given a NeedSpec and a context (what the node serves, what the host serves, what is
|
|
5
|
+
installable, whether the node can be managed), pick the FIRST feasible strategy in cost
|
|
6
|
+
order — never install blindly, never reach for a GUI when a headless path exists:
|
|
7
|
+
|
|
8
|
+
1. DIRECT a capability is already served on the node (cheapest, headless)
|
|
9
|
+
2. HOST_FALLBACK a headless capability runs host-side + sync (no node change)
|
|
10
|
+
3. INSTALL a connector provides it and the node can manage (fixes the node)
|
|
11
|
+
4. GUI a GUI capability, only as a last resort
|
|
12
|
+
5. BLOCKED nothing feasible → honest, with what was tried
|
|
13
|
+
|
|
14
|
+
Every plan carries a postcondition (from the need's required_outputs) so the verifier
|
|
15
|
+
can confirm the artifact actually exists.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import capabilities as _caps
|
|
23
|
+
from . import gates as _gates
|
|
24
|
+
from . import needs as _needs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Context:
|
|
29
|
+
node: str
|
|
30
|
+
node_served: list[str] = field(default_factory=list)
|
|
31
|
+
host_served: list[str] = field(default_factory=list)
|
|
32
|
+
installable: set = field(default_factory=set)
|
|
33
|
+
can_manage: bool = False
|
|
34
|
+
allow_gui: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _postcondition(need: _needs.NeedSpec, node: str) -> dict | None:
|
|
38
|
+
for out in need.required_outputs:
|
|
39
|
+
if out.startswith("fs://") or out.startswith("artifact://"):
|
|
40
|
+
return {"uri": out.replace("*", node, 1) if "{node}" not in out else out.replace("{node}", node),
|
|
41
|
+
"check": "exists"}
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _direct(need, ctx) -> dict | None:
|
|
46
|
+
for cap_id in need.capabilities():
|
|
47
|
+
cap = _caps.get(cap_id)
|
|
48
|
+
if not cap or cap.gui:
|
|
49
|
+
continue
|
|
50
|
+
route = _caps.served_route(cap_id, ctx.node, ctx.node_served)
|
|
51
|
+
if route:
|
|
52
|
+
return {"strategy": "direct", "capability": cap_id,
|
|
53
|
+
"steps": [{"uri": route, "for": cap_id}]}
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _host_fallback(need, ctx) -> dict | None:
|
|
58
|
+
for cap_id in need.capabilities():
|
|
59
|
+
cap = _caps.get(cap_id)
|
|
60
|
+
if not cap or cap.gui or not cap.host_ok:
|
|
61
|
+
continue
|
|
62
|
+
route = _caps.served_route(cap_id, "host", ctx.host_served)
|
|
63
|
+
if route:
|
|
64
|
+
return {"strategy": "host_fallback", "capability": cap_id,
|
|
65
|
+
"steps": [{"uri": route, "for": cap_id, "note": "run on host, then sync to node"}]}
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _install(need, ctx) -> dict | None:
|
|
70
|
+
if not ctx.can_manage:
|
|
71
|
+
return None
|
|
72
|
+
for cap_id in need.capabilities():
|
|
73
|
+
cap = _caps.get(cap_id)
|
|
74
|
+
if not cap or cap.gui:
|
|
75
|
+
continue
|
|
76
|
+
for conn in cap.connectors:
|
|
77
|
+
if conn in ctx.installable:
|
|
78
|
+
n = ctx.node
|
|
79
|
+
return {"strategy": "install_then_run", "capability": cap_id, "connector": conn,
|
|
80
|
+
"steps": [
|
|
81
|
+
{"uri": f"node://{n}/connector/command/install", "payload": {"id": conn}},
|
|
82
|
+
{"uri": f"node://{n}/registry/command/rebuild"},
|
|
83
|
+
{"uri": f"node://{n}/runtime/command/restart", "note": "drop stale workers"},
|
|
84
|
+
{"uri": f"node://{n}/smoke/command/run"},
|
|
85
|
+
{"uri": _caps.install_route(cap_id, n), "for": cap_id},
|
|
86
|
+
]}
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _gui(need, ctx) -> dict | None:
|
|
91
|
+
if not ctx.allow_gui:
|
|
92
|
+
return None
|
|
93
|
+
for cap_id in need.capabilities():
|
|
94
|
+
cap = _caps.get(cap_id)
|
|
95
|
+
if cap and cap.gui:
|
|
96
|
+
route = _caps.served_route(cap_id, ctx.node, ctx.node_served)
|
|
97
|
+
if route:
|
|
98
|
+
return {"strategy": "gui", "capability": cap_id, "steps": [{"uri": route, "for": cap_id}]}
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# strategy ladder, cheapest/safest first — GUI last, blind install never
|
|
103
|
+
_LADDER = (_direct, _host_fallback, _install, _gui)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve(need: _needs.NeedSpec, ctx: Context) -> dict[str, Any]:
|
|
107
|
+
"""Walk the ladder; return the first feasible plan (+ postcondition), else blocked."""
|
|
108
|
+
tried = []
|
|
109
|
+
for strat in _LADDER:
|
|
110
|
+
plan = strat(need, ctx)
|
|
111
|
+
if plan:
|
|
112
|
+
plan["steps"] = _gates.apply(plan["steps"]) # insert approval:// before gated steps
|
|
113
|
+
plan["need"] = need.id
|
|
114
|
+
plan["postcondition"] = _postcondition(need, ctx.node)
|
|
115
|
+
plan["resolved"] = True
|
|
116
|
+
return plan
|
|
117
|
+
tried.append(strat.__name__.lstrip("_"))
|
|
118
|
+
return {"resolved": False, "need": need.id, "tried": tried,
|
|
119
|
+
"reason": "no served route, no host fallback, no installable connector, GUI not allowed/available"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urirun-reasoner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Intent → need → capability → URI plan, headless-first. Model the goal not the app: a missing GUI editor is one blocked method, not the end. Deterministic core, LLM only as fallback.
|
|
5
|
+
Author: Tom Sapletta
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# urirun-reasoner
|
|
11
|
+
|
|
12
|
+
Intent → need → capability → URI plan, **headless-first**. Model the GOAL, not the app.
|
|
13
|
+
|
|
14
|
+
A missing GUI editor is one blocked method in a graph — not the end of the road. Asked to
|
|
15
|
+
"test office on lenovo", the reasoner does not stop at "no LibreOffice"; it reframes to
|
|
16
|
+
"produce a document artifact and verify it exists" and resolves it through the cheapest
|
|
17
|
+
feasible URI path.
|
|
18
|
+
|
|
19
|
+
## The resolution ladder (kernel decides, cheapest/safest first)
|
|
20
|
+
|
|
21
|
+
1. **direct** — a capability is already served on the node (headless)
|
|
22
|
+
2. **host_fallback** — a headless capability runs host-side + sync (no node change)
|
|
23
|
+
3. **install_then_run** — a connector provides it and the node can manage (install → rebuild → restart → smoke → use)
|
|
24
|
+
4. **gui** — a GUI capability, only as a last resort
|
|
25
|
+
5. **blocked** — nothing feasible → honest, with what was tried
|
|
26
|
+
|
|
27
|
+
GUI is never chosen when a headless path exists; connectors are never installed blindly.
|
|
28
|
+
|
|
29
|
+
## Layers
|
|
30
|
+
|
|
31
|
+
| module | role |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `needs.py` | `NeedSpec` — the intent contract (goal, required outputs, preferred/acceptable capabilities, requires_gui) + a catalog |
|
|
34
|
+
| `capabilities.py` | `CapabilityMap` — capability → URI route templates + connectors (the layer between language and URIs) |
|
|
35
|
+
| `intent.py` | prompt → NeedSpec (rules first; LLM only names the intent, never invents URIs) |
|
|
36
|
+
| `planner.py` | the deterministic ladder + `resolve(need, ctx)` with a postcondition |
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from urirun_reasoner import Context, resolve_prompt
|
|
42
|
+
ctx = Context(node="lenovo", node_served=[...], host_served=["fs://host/file/command/write"],
|
|
43
|
+
installable={"sheet"}, can_manage=True)
|
|
44
|
+
plan = resolve_prompt("przetestuj biuro na lenovo", ctx)
|
|
45
|
+
# → {"strategy": "host_fallback", "steps": [...], "postcondition": {...}}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pairs with `urirun-fleet` (node readiness + execution) and the dispatch `_meta` provenance
|
|
49
|
+
(so the reasoner knows what is actually served). Part of the ifURI solution · Apache-2.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_reasoner.py
|
|
4
|
+
urirun_reasoner/__init__.py
|
|
5
|
+
urirun_reasoner/capabilities.py
|
|
6
|
+
urirun_reasoner/cli.py
|
|
7
|
+
urirun_reasoner/gates.py
|
|
8
|
+
urirun_reasoner/generation_policy.py
|
|
9
|
+
urirun_reasoner/generator.py
|
|
10
|
+
urirun_reasoner/intent.py
|
|
11
|
+
urirun_reasoner/needs.py
|
|
12
|
+
urirun_reasoner/planner.py
|
|
13
|
+
urirun_reasoner.egg-info/PKG-INFO
|
|
14
|
+
urirun_reasoner.egg-info/SOURCES.txt
|
|
15
|
+
urirun_reasoner.egg-info/dependency_links.txt
|
|
16
|
+
urirun_reasoner.egg-info/entry_points.txt
|
|
17
|
+
urirun_reasoner.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
urirun_reasoner
|