flowdesk-omnigent-tool 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.
- flowdesk_omnigent_tool-0.1.0/.gitignore +8 -0
- flowdesk_omnigent_tool-0.1.0/LICENSE +21 -0
- flowdesk_omnigent_tool-0.1.0/PKG-INFO +126 -0
- flowdesk_omnigent_tool-0.1.0/README.md +103 -0
- flowdesk_omnigent_tool-0.1.0/pyproject.toml +51 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/__init__.py +14 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/mcp_server.py +100 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/policies.py +251 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/py.typed +0 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/selection.py +601 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/trace_adapter.py +175 -0
- flowdesk_omnigent_tool-0.1.0/src/flowdesk_omnigent/trace_verifier.py +146 -0
- flowdesk_omnigent_tool-0.1.0/tests/fixtures/omnigent_function_history_selection_dispatch.json +54 -0
- flowdesk_omnigent_tool-0.1.0/tests/test_mcp_server.py +80 -0
- flowdesk_omnigent_tool-0.1.0/tests/test_policies.py +204 -0
- flowdesk_omnigent_tool-0.1.0/tests/test_selection.py +251 -0
- flowdesk_omnigent_tool-0.1.0/tests/test_trace_adapter.py +209 -0
- flowdesk_omnigent_tool-0.1.0/tests/test_trace_verifier.py +155 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FlowDesk contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowdesk-omnigent-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FlowDesk advisory selection tool for Omnigent.
|
|
5
|
+
Project-URL: Homepage, https://github.com/astasdf1/flowdesk
|
|
6
|
+
Project-URL: Repository, https://github.com/astasdf1/flowdesk
|
|
7
|
+
Project-URL: Issues, https://github.com/astasdf1/flowdesk/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/astasdf1/flowdesk/tree/main/docs/omnigent
|
|
9
|
+
Author: FlowDesk
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent-selection,flowdesk,mcp,model-selection,omnigent
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# FlowDesk Omnigent Tool
|
|
25
|
+
|
|
26
|
+
Experimental Omnigent integration package for FlowDesk advisory agent/model selection.
|
|
27
|
+
|
|
28
|
+
This package implements the experimental Omnigent selection path from `docs/omnigent/OMNIGENT_DEVELOPMENT_DESIGN_OPTIONS.md`:
|
|
29
|
+
|
|
30
|
+
- local Python function tool only;
|
|
31
|
+
- advisory selection only;
|
|
32
|
+
- no Omnigent dispatch calls;
|
|
33
|
+
- no provider fallback/retry authority;
|
|
34
|
+
- no credential/token file reads;
|
|
35
|
+
- optional local debug JSONL only when explicitly requested by the caller.
|
|
36
|
+
- post-run trace verification from Omnigent history/tool-call events;
|
|
37
|
+
- optional fixture-level function policy guard for selector-provenance and binding consistency.
|
|
38
|
+
|
|
39
|
+
Install into an Omnigent venv from PyPI:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv pip install --python /path/to/omnigent/.venv/bin/python flowdesk-omnigent-tool
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Development install from a FlowDesk checkout:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv pip install --python /path/to/omnigent/.venv/bin/python -e packages/omnigent-tool
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Release preflight from the FlowDesk repository:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm run release:omnigent-tool
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Publishing is explicit:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm run release:omnigent-tool -- --test-pypi
|
|
61
|
+
npm run release:omnigent-tool -- --publish
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
GitHub Actions Trusted Publishing is available through `.github/workflows/publish-omnigent-tool.yml`. Configure PyPI with workflow filename `publish-omnigent-tool.yml` and environment `pypi`; configure TestPyPI with environment `testpypi` if you want a TestPyPI dry run.
|
|
65
|
+
|
|
66
|
+
Function path for Omnigent config:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
flowdesk_omnigent.selection.select_agent_model
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The selector accepts optional `provider_usage` or `provider_health` snapshots. Exhausted, critical, stale, blocked, unavailable, non-dispatchable, or 0%-remaining provider rows are skipped when another allowed provider can satisfy the request. If every allowed provider is unavailable, the selector returns `selection_status=blocked` with `provider_usage_unavailable`.
|
|
73
|
+
|
|
74
|
+
Trace verifier import path:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
flowdesk_omnigent.trace_verifier.verify_selection_dispatch_trace
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Trace adapter import path:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
flowdesk_omnigent.trace_adapter.normalize_omnigent_trace_events
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Minimal adapter/verifier flow:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from flowdesk_omnigent.trace_adapter import normalize_omnigent_trace_events
|
|
90
|
+
from flowdesk_omnigent.trace_verifier import verify_selection_dispatch_trace
|
|
91
|
+
|
|
92
|
+
normalized = normalize_omnigent_trace_events(history_items)
|
|
93
|
+
verification = verify_selection_dispatch_trace(normalized["events"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The adapter returns redaction-safe normalized events only. It does not preserve raw prompts, full tool arguments, full tool outputs, provider payloads, or credentials.
|
|
97
|
+
|
|
98
|
+
Optional MCP stdio server:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
flowdesk-omnigent-mcp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Omnigent MCP config after PyPI install:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
tools:
|
|
108
|
+
flowdesk:
|
|
109
|
+
type: mcp
|
|
110
|
+
command: flowdesk-omnigent-mcp
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The MCP server exposes only `flowdesk_select_agent_model`. It is selection-only and does not expose Omnigent dispatch, fallback, retry, write/apply, or provider-switch tools.
|
|
114
|
+
|
|
115
|
+
Optional Omnigent function policy guard:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
guardrails:
|
|
119
|
+
policies:
|
|
120
|
+
flowdesk_selection_dispatch_guard:
|
|
121
|
+
type: function
|
|
122
|
+
on: [tool_call, tool_result]
|
|
123
|
+
function: flowdesk_omnigent.policies.omnigent_selection_dispatch_guard
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The guard records FlowDesk selector calls in Omnigent policy state and, when Omnigent emits a selector `tool_result`, records exact selector output provenance. Guarded `sys_session_send` calls must match a recorded task/agent/harness/model binding and must not use expired selection records. This remains fixture-level policy enforcement only; it does not grant provider fallback, retry, write/apply, hard-chat/noReply, credential, or upstream Omnigent core-hook authority.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# FlowDesk Omnigent Tool
|
|
2
|
+
|
|
3
|
+
Experimental Omnigent integration package for FlowDesk advisory agent/model selection.
|
|
4
|
+
|
|
5
|
+
This package implements the experimental Omnigent selection path from `docs/omnigent/OMNIGENT_DEVELOPMENT_DESIGN_OPTIONS.md`:
|
|
6
|
+
|
|
7
|
+
- local Python function tool only;
|
|
8
|
+
- advisory selection only;
|
|
9
|
+
- no Omnigent dispatch calls;
|
|
10
|
+
- no provider fallback/retry authority;
|
|
11
|
+
- no credential/token file reads;
|
|
12
|
+
- optional local debug JSONL only when explicitly requested by the caller.
|
|
13
|
+
- post-run trace verification from Omnigent history/tool-call events;
|
|
14
|
+
- optional fixture-level function policy guard for selector-provenance and binding consistency.
|
|
15
|
+
|
|
16
|
+
Install into an Omnigent venv from PyPI:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv pip install --python /path/to/omnigent/.venv/bin/python flowdesk-omnigent-tool
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Development install from a FlowDesk checkout:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv pip install --python /path/to/omnigent/.venv/bin/python -e packages/omnigent-tool
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Release preflight from the FlowDesk repository:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm run release:omnigent-tool
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Publishing is explicit:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm run release:omnigent-tool -- --test-pypi
|
|
38
|
+
npm run release:omnigent-tool -- --publish
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
GitHub Actions Trusted Publishing is available through `.github/workflows/publish-omnigent-tool.yml`. Configure PyPI with workflow filename `publish-omnigent-tool.yml` and environment `pypi`; configure TestPyPI with environment `testpypi` if you want a TestPyPI dry run.
|
|
42
|
+
|
|
43
|
+
Function path for Omnigent config:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
flowdesk_omnigent.selection.select_agent_model
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The selector accepts optional `provider_usage` or `provider_health` snapshots. Exhausted, critical, stale, blocked, unavailable, non-dispatchable, or 0%-remaining provider rows are skipped when another allowed provider can satisfy the request. If every allowed provider is unavailable, the selector returns `selection_status=blocked` with `provider_usage_unavailable`.
|
|
50
|
+
|
|
51
|
+
Trace verifier import path:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
flowdesk_omnigent.trace_verifier.verify_selection_dispatch_trace
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Trace adapter import path:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
flowdesk_omnigent.trace_adapter.normalize_omnigent_trace_events
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Minimal adapter/verifier flow:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from flowdesk_omnigent.trace_adapter import normalize_omnigent_trace_events
|
|
67
|
+
from flowdesk_omnigent.trace_verifier import verify_selection_dispatch_trace
|
|
68
|
+
|
|
69
|
+
normalized = normalize_omnigent_trace_events(history_items)
|
|
70
|
+
verification = verify_selection_dispatch_trace(normalized["events"])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The adapter returns redaction-safe normalized events only. It does not preserve raw prompts, full tool arguments, full tool outputs, provider payloads, or credentials.
|
|
74
|
+
|
|
75
|
+
Optional MCP stdio server:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
flowdesk-omnigent-mcp
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Omnigent MCP config after PyPI install:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
tools:
|
|
85
|
+
flowdesk:
|
|
86
|
+
type: mcp
|
|
87
|
+
command: flowdesk-omnigent-mcp
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The MCP server exposes only `flowdesk_select_agent_model`. It is selection-only and does not expose Omnigent dispatch, fallback, retry, write/apply, or provider-switch tools.
|
|
91
|
+
|
|
92
|
+
Optional Omnigent function policy guard:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
guardrails:
|
|
96
|
+
policies:
|
|
97
|
+
flowdesk_selection_dispatch_guard:
|
|
98
|
+
type: function
|
|
99
|
+
on: [tool_call, tool_result]
|
|
100
|
+
function: flowdesk_omnigent.policies.omnigent_selection_dispatch_guard
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The guard records FlowDesk selector calls in Omnigent policy state and, when Omnigent emits a selector `tool_result`, records exact selector output provenance. Guarded `sys_session_send` calls must match a recorded task/agent/harness/model binding and must not use expired selection records. This remains fixture-level policy enforcement only; it does not grant provider fallback, retry, write/apply, hard-chat/noReply, credential, or upstream Omnigent core-hook authority.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flowdesk-omnigent-tool"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "FlowDesk advisory selection tool for Omnigent."
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "FlowDesk" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
"Typing :: Typed"
|
|
24
|
+
]
|
|
25
|
+
keywords = ["flowdesk", "omnigent", "mcp", "agent-selection", "model-selection"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/astasdf1/flowdesk"
|
|
29
|
+
Repository = "https://github.com/astasdf1/flowdesk"
|
|
30
|
+
Issues = "https://github.com/astasdf1/flowdesk/issues"
|
|
31
|
+
Documentation = "https://github.com/astasdf1/flowdesk/tree/main/docs/omnigent"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
flowdesk-omnigent-mcp = "flowdesk_omnigent.mcp_server:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/flowdesk_omnigent"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = [
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"src/flowdesk_omnigent/**/*.py",
|
|
44
|
+
"src/flowdesk_omnigent/py.typed",
|
|
45
|
+
"tests/**/*.py",
|
|
46
|
+
"tests/fixtures/**/*.json",
|
|
47
|
+
"pyproject.toml"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""FlowDesk Omnigent integration package."""
|
|
2
|
+
|
|
3
|
+
from .selection import flowdesk_select_agent_model, select_agent_model
|
|
4
|
+
from .policies import omnigent_selection_dispatch_guard
|
|
5
|
+
from .trace_adapter import normalize_omnigent_trace_events
|
|
6
|
+
from .trace_verifier import verify_selection_dispatch_trace
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"flowdesk_select_agent_model",
|
|
10
|
+
"normalize_omnigent_trace_events",
|
|
11
|
+
"omnigent_selection_dispatch_guard",
|
|
12
|
+
"select_agent_model",
|
|
13
|
+
"verify_selection_dispatch_trace",
|
|
14
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Minimal stdio MCP server for FlowDesk Omnigent advisory selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .selection import select_agent_model
|
|
10
|
+
|
|
11
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
12
|
+
MCP_TOOL_NAME = "flowdesk_select_agent_model"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handle_mcp_request(request: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
16
|
+
"""Handle one JSON-RPC request without granting runtime authority."""
|
|
17
|
+
|
|
18
|
+
method = request.get("method")
|
|
19
|
+
request_id = request.get("id")
|
|
20
|
+
if method == "notifications/initialized":
|
|
21
|
+
return None
|
|
22
|
+
if method == "initialize":
|
|
23
|
+
return _result(
|
|
24
|
+
request_id,
|
|
25
|
+
{
|
|
26
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
27
|
+
"capabilities": {"tools": {}},
|
|
28
|
+
"serverInfo": {"name": "flowdesk-omnigent-mcp", "version": "0.1.0"},
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
if method == "tools/list":
|
|
32
|
+
return _result(request_id, {"tools": [_tool_definition()]})
|
|
33
|
+
if method == "tools/call":
|
|
34
|
+
params = request.get("params")
|
|
35
|
+
if not isinstance(params, Mapping) or params.get("name") != MCP_TOOL_NAME:
|
|
36
|
+
return _error(request_id, -32602, "unsupported tool")
|
|
37
|
+
arguments = params.get("arguments")
|
|
38
|
+
if not isinstance(arguments, Mapping):
|
|
39
|
+
arguments = {}
|
|
40
|
+
selection = select_agent_model(arguments, write_evidence=False)
|
|
41
|
+
return _result(
|
|
42
|
+
request_id,
|
|
43
|
+
{
|
|
44
|
+
"content": [{"type": "text", "text": json.dumps(selection, ensure_ascii=True, sort_keys=True, separators=(",", ":"))}],
|
|
45
|
+
"isError": False,
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
return _error(request_id, -32601, "method not found")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
"""Run a line-delimited JSON-RPC stdio loop."""
|
|
53
|
+
|
|
54
|
+
for line in sys.stdin:
|
|
55
|
+
if not line.strip():
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
request = json.loads(line)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
response = _error(None, -32700, "parse error")
|
|
61
|
+
else:
|
|
62
|
+
response = handle_mcp_request(request) if isinstance(request, Mapping) else _error(None, -32600, "invalid request")
|
|
63
|
+
if response is not None:
|
|
64
|
+
sys.stdout.write(json.dumps(response, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
|
65
|
+
sys.stdout.write("\n")
|
|
66
|
+
sys.stdout.flush()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tool_definition() -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"name": MCP_TOOL_NAME,
|
|
72
|
+
"description": "Return advisory-only FlowDesk agent/model selection for an Omnigent subtask.",
|
|
73
|
+
"inputSchema": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"additionalProperties": False,
|
|
76
|
+
"properties": {
|
|
77
|
+
"task_id": {"type": "string"},
|
|
78
|
+
"task_role": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"enum": ["policy_security", "architecture", "implementation", "verification", "research", "general", "gemini_experimental"],
|
|
81
|
+
},
|
|
82
|
+
"allowed_provider_families": {"type": "array", "items": {"type": "string", "enum": ["claude", "openai", "gemini"]}},
|
|
83
|
+
"preferred_provider_family": {"type": "string", "enum": ["claude", "openai", "gemini"]},
|
|
84
|
+
"requires_headless": {"type": "boolean"},
|
|
85
|
+
},
|
|
86
|
+
"required": ["task_id", "task_role"],
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _result(request_id: Any, result: Mapping[str, Any]) -> dict[str, Any]:
|
|
92
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": dict(result)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
96
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Omnigent policy helpers for FlowDesk selection consistency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from .selection import select_agent_model
|
|
10
|
+
|
|
11
|
+
DEFAULT_AGENT_MODEL_BINDINGS: Mapping[str, str | None] = {
|
|
12
|
+
"policy-security-agent": "claude-opus-4-8",
|
|
13
|
+
"architecture-agent": None,
|
|
14
|
+
"implementation-agent": None,
|
|
15
|
+
"verification-agent": None,
|
|
16
|
+
}
|
|
17
|
+
DEFAULT_AGENT_HARNESS_BINDINGS: Mapping[str, str] = {
|
|
18
|
+
"policy-security-agent": "claude-sdk",
|
|
19
|
+
"architecture-agent": "codex",
|
|
20
|
+
"implementation-agent": "codex",
|
|
21
|
+
"verification-agent": "codex",
|
|
22
|
+
}
|
|
23
|
+
FLOWDESK_SELECTION_STATE_KEY = "flowdesk_selection_events"
|
|
24
|
+
FLOWDESK_SELECTOR_TARGETS = frozenset({"flowdesk_select_agent_model", "select_agent_model", "mcp__flowdesk__flowdesk_select_agent_model"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_omnigent_selection_dispatch_guard(
|
|
28
|
+
*,
|
|
29
|
+
guarded_agents: Sequence[str] | None = None,
|
|
30
|
+
allow_unknown_agents: bool = True,
|
|
31
|
+
require_selection_provenance: bool = True,
|
|
32
|
+
) -> Callable[[Mapping[str, Any]], dict[str, Any] | None]:
|
|
33
|
+
"""Create a tool_call policy that denies selection-incompatible dispatch.
|
|
34
|
+
|
|
35
|
+
This is a mechanical pre-dispatch guard for the FlowDesk Omnigent fixture.
|
|
36
|
+
It does not prove that a selector tool was called earlier; it only ensures
|
|
37
|
+
that guarded ``sys_session_send`` calls match FlowDesk's current static
|
|
38
|
+
advisory binding contract.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
guarded = set(guarded_agents or DEFAULT_AGENT_MODEL_BINDINGS.keys())
|
|
42
|
+
|
|
43
|
+
def evaluate(event: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
44
|
+
event_type = event.get("type")
|
|
45
|
+
if event_type not in {"tool_call", "tool_result"}:
|
|
46
|
+
return None
|
|
47
|
+
target = event.get("target")
|
|
48
|
+
if event_type == "tool_result" and isinstance(target, str) and _is_flowdesk_selector_target(target):
|
|
49
|
+
return _record_selection_output_event(event)
|
|
50
|
+
if event_type == "tool_result":
|
|
51
|
+
return None
|
|
52
|
+
if event_type == "tool_call" and isinstance(target, str) and _is_flowdesk_selector_target(target):
|
|
53
|
+
return _record_recomputed_selection_event(event)
|
|
54
|
+
if target != "sys_session_send":
|
|
55
|
+
return None
|
|
56
|
+
data = event.get("data")
|
|
57
|
+
if not isinstance(data, Mapping):
|
|
58
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: malformed tool_call data."}
|
|
59
|
+
arguments = data.get("arguments")
|
|
60
|
+
if not isinstance(arguments, Mapping):
|
|
61
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: malformed sys_session_send arguments."}
|
|
62
|
+
agent = arguments.get("agent")
|
|
63
|
+
if not isinstance(agent, str) or not agent:
|
|
64
|
+
return None
|
|
65
|
+
if agent not in guarded:
|
|
66
|
+
if allow_unknown_agents:
|
|
67
|
+
return None
|
|
68
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: unregistered sub-agent."}
|
|
69
|
+
if require_selection_provenance:
|
|
70
|
+
matching_selection = _matching_recorded_selection(event, arguments)
|
|
71
|
+
if matching_selection is None:
|
|
72
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: no matching FlowDesk selection was recorded for this dispatch."}
|
|
73
|
+
if matching_selection.get("selection_status") != "selected":
|
|
74
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: recorded selection was not dispatchable."}
|
|
75
|
+
if _selection_is_expired(matching_selection):
|
|
76
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: recorded selection has expired."}
|
|
77
|
+
expected_model = matching_selection.get("model")
|
|
78
|
+
expected_harness = matching_selection.get("harness")
|
|
79
|
+
else:
|
|
80
|
+
expected_model = DEFAULT_AGENT_MODEL_BINDINGS.get(agent)
|
|
81
|
+
expected_harness = DEFAULT_AGENT_HARNESS_BINDINGS.get(agent)
|
|
82
|
+
actual_harness = _dispatch_harness(arguments)
|
|
83
|
+
effective_harness = actual_harness or DEFAULT_AGENT_HARNESS_BINDINGS.get(agent)
|
|
84
|
+
if isinstance(expected_harness, str) and effective_harness != expected_harness:
|
|
85
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: harness does not match selected binding."}
|
|
86
|
+
actual_model = _dispatch_model(arguments)
|
|
87
|
+
if expected_model is None and actual_model is not None:
|
|
88
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: this sub-agent must use harness default model."}
|
|
89
|
+
if expected_model is not None and actual_model != expected_model:
|
|
90
|
+
return {"result": "DENY", "reason": "FlowDesk dispatch guard: model override does not match selected binding."}
|
|
91
|
+
return {"result": "ALLOW"}
|
|
92
|
+
|
|
93
|
+
return evaluate
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def omnigent_selection_dispatch_guard(event: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
97
|
+
"""Direct Omnigent policy callable for config.yaml function policies."""
|
|
98
|
+
|
|
99
|
+
return make_omnigent_selection_dispatch_guard()(event)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dispatch_model(arguments: Mapping[str, Any]) -> str | None:
|
|
103
|
+
args = arguments.get("args")
|
|
104
|
+
if isinstance(args, Mapping):
|
|
105
|
+
model = args.get("model")
|
|
106
|
+
return model if isinstance(model, str) and model else None
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _dispatch_harness(arguments: Mapping[str, Any]) -> str | None:
|
|
111
|
+
harness = arguments.get("harness")
|
|
112
|
+
return harness if isinstance(harness, str) and harness else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _record_recomputed_selection_event(event: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
116
|
+
data = event.get("data")
|
|
117
|
+
if not isinstance(data, Mapping):
|
|
118
|
+
return None
|
|
119
|
+
arguments = data.get("arguments")
|
|
120
|
+
if not isinstance(arguments, Mapping):
|
|
121
|
+
arguments = {}
|
|
122
|
+
selection = select_agent_model(arguments, write_evidence=False)
|
|
123
|
+
record = _record_from_selection(selection, provenance_source="selector_args_recomputed")
|
|
124
|
+
if record is None:
|
|
125
|
+
return None
|
|
126
|
+
return {"result": "ALLOW", "state_updates": [{"key": FLOWDESK_SELECTION_STATE_KEY, "action": "append", "value": record}]}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _record_selection_output_event(event: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
130
|
+
selection = _selection_from_tool_result(event)
|
|
131
|
+
if selection is None:
|
|
132
|
+
return {"result": "ALLOW"}
|
|
133
|
+
record = _record_from_selection(selection, provenance_source="selector_output")
|
|
134
|
+
if record is None:
|
|
135
|
+
return {"result": "ALLOW"}
|
|
136
|
+
return {"result": "ALLOW", "state_updates": [{"key": FLOWDESK_SELECTION_STATE_KEY, "action": "append", "value": record}]}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _selection_from_tool_result(event: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
140
|
+
data = event.get("data")
|
|
141
|
+
payload: Any = None
|
|
142
|
+
if isinstance(data, Mapping):
|
|
143
|
+
payload = data.get("output") or data.get("result") or data.get("content")
|
|
144
|
+
elif isinstance(data, str):
|
|
145
|
+
payload = data
|
|
146
|
+
selection = _coerce_selection_payload(payload)
|
|
147
|
+
if selection is not None:
|
|
148
|
+
return selection
|
|
149
|
+
if isinstance(data, Mapping):
|
|
150
|
+
content = data.get("content")
|
|
151
|
+
if isinstance(content, list):
|
|
152
|
+
for item in content:
|
|
153
|
+
if isinstance(item, Mapping) and item.get("type") == "text":
|
|
154
|
+
selection = _coerce_selection_payload(item.get("text"))
|
|
155
|
+
if selection is not None:
|
|
156
|
+
return selection
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _coerce_selection_payload(value: Any) -> Mapping[str, Any] | None:
|
|
161
|
+
if isinstance(value, Mapping):
|
|
162
|
+
payload = dict(value)
|
|
163
|
+
elif isinstance(value, str) and value.strip():
|
|
164
|
+
try:
|
|
165
|
+
parsed = json.loads(value)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return None
|
|
168
|
+
if not isinstance(parsed, Mapping):
|
|
169
|
+
return None
|
|
170
|
+
payload = dict(parsed)
|
|
171
|
+
else:
|
|
172
|
+
return None
|
|
173
|
+
if isinstance(payload.get("result"), Mapping):
|
|
174
|
+
payload = dict(payload["result"])
|
|
175
|
+
if payload.get("schema_version") != "flowdesk.omnigent_selection.v1":
|
|
176
|
+
return None
|
|
177
|
+
if payload.get("authority") != "advisory_selection_only":
|
|
178
|
+
return None
|
|
179
|
+
return payload
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _record_from_selection(selection: Mapping[str, Any], *, provenance_source: str) -> dict[str, Any] | None:
|
|
183
|
+
task_id = selection.get("task_id")
|
|
184
|
+
status = selection.get("selection_status")
|
|
185
|
+
if not isinstance(task_id, str) or not task_id or status not in {"selected", "blocked", "non_dispatchable"}:
|
|
186
|
+
return None
|
|
187
|
+
return {
|
|
188
|
+
"task_id": task_id,
|
|
189
|
+
"selection_status": status,
|
|
190
|
+
"agent": selection.get("agent"),
|
|
191
|
+
"harness": selection.get("harness"),
|
|
192
|
+
"model": selection.get("model"),
|
|
193
|
+
"selection_id": selection.get("selection_id"),
|
|
194
|
+
"expires_at": selection.get("expires_at"),
|
|
195
|
+
"provenance_source": provenance_source,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _matching_recorded_selection(event: Mapping[str, Any], arguments: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
200
|
+
state = event.get("session_state")
|
|
201
|
+
if not isinstance(state, Mapping):
|
|
202
|
+
return None
|
|
203
|
+
records = state.get(FLOWDESK_SELECTION_STATE_KEY)
|
|
204
|
+
if not isinstance(records, list):
|
|
205
|
+
return None
|
|
206
|
+
task_id = _dispatch_task_id(arguments)
|
|
207
|
+
agent = arguments.get("agent")
|
|
208
|
+
model = _dispatch_model(arguments)
|
|
209
|
+
for raw in reversed(records):
|
|
210
|
+
if not isinstance(raw, Mapping):
|
|
211
|
+
continue
|
|
212
|
+
if task_id is not None and raw.get("task_id") != task_id:
|
|
213
|
+
continue
|
|
214
|
+
if raw.get("agent") != agent:
|
|
215
|
+
continue
|
|
216
|
+
if _selection_is_expired(raw):
|
|
217
|
+
continue
|
|
218
|
+
expected_model = raw.get("model")
|
|
219
|
+
if expected_model is None and model is not None:
|
|
220
|
+
continue
|
|
221
|
+
if expected_model is not None and expected_model != model:
|
|
222
|
+
continue
|
|
223
|
+
return raw
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _dispatch_task_id(arguments: Mapping[str, Any]) -> str | None:
|
|
228
|
+
args = arguments.get("args")
|
|
229
|
+
if isinstance(args, Mapping):
|
|
230
|
+
task_id = args.get("task_id")
|
|
231
|
+
if isinstance(task_id, str) and task_id:
|
|
232
|
+
return task_id
|
|
233
|
+
title = arguments.get("title")
|
|
234
|
+
return title if isinstance(title, str) and title else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_flowdesk_selector_target(target: str) -> bool:
|
|
238
|
+
return target in FLOWDESK_SELECTOR_TARGETS or target.endswith("__flowdesk_select_agent_model")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _selection_is_expired(selection: Mapping[str, Any]) -> bool:
|
|
242
|
+
expires_at = selection.get("expires_at")
|
|
243
|
+
if not isinstance(expires_at, str) or not expires_at:
|
|
244
|
+
return False
|
|
245
|
+
try:
|
|
246
|
+
parsed = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
247
|
+
except ValueError:
|
|
248
|
+
return True
|
|
249
|
+
if parsed.tzinfo is None:
|
|
250
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
251
|
+
return parsed <= datetime.now(timezone.utc)
|
|
File without changes
|