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.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ .omc/
3
+ .tools/
4
+ .codegraph/
5
+ .flowdesk/
6
+ dist/
7
+ packages/*/dist/
8
+ *.tsbuildinfo
@@ -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)