asfops 0.1.0__py3-none-any.whl
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.
- asfops/__init__.py +83 -0
- asfops/_version.py +1 -0
- asfops/api.py +128 -0
- asfops/cli/__init__.py +5 -0
- asfops/cli/app.py +282 -0
- asfops/cli/render.py +76 -0
- asfops/config.py +56 -0
- asfops/exceptions.py +33 -0
- asfops/fleet/__init__.py +31 -0
- asfops/fleet/member.py +20 -0
- asfops/fleet/roles.py +57 -0
- asfops/fleet/roster.py +285 -0
- asfops/fleet/schemas.py +90 -0
- asfops/models/__init__.py +18 -0
- asfops/models/bridge.py +136 -0
- asfops/models/client.py +58 -0
- asfops/models/copilot.py +382 -0
- asfops/models/resolve.py +23 -0
- asfops/orchestrator.py +268 -0
- asfops/py.typed +0 -0
- asfops/results.py +330 -0
- asfops-0.1.0.dist-info/METADATA +118 -0
- asfops-0.1.0.dist-info/RECORD +26 -0
- asfops-0.1.0.dist-info/WHEEL +4 -0
- asfops-0.1.0.dist-info/entry_points.txt +2 -0
- asfops-0.1.0.dist-info/licenses/LICENSE +21 -0
asfops/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""asfops — Agentic Security Fleet Ops.
|
|
2
|
+
|
|
3
|
+
A security department of LLM agents: a Security Orchestrator triages any
|
|
4
|
+
assessment request, fans out to the relevant fleet members, and composes a
|
|
5
|
+
single comprehensive markdown report with optional usage metadata.
|
|
6
|
+
|
|
7
|
+
Quickstart::
|
|
8
|
+
|
|
9
|
+
import asfops
|
|
10
|
+
|
|
11
|
+
result = asfops.assess_sync("Review our new file-upload API design")
|
|
12
|
+
print(result.report_md)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from asfops._version import __version__
|
|
16
|
+
from asfops.api import (
|
|
17
|
+
Fleet,
|
|
18
|
+
assess,
|
|
19
|
+
assess_sync,
|
|
20
|
+
compose_report,
|
|
21
|
+
list_roles,
|
|
22
|
+
)
|
|
23
|
+
from asfops.config import FleetConfig
|
|
24
|
+
from asfops.exceptions import (
|
|
25
|
+
AsfopsError,
|
|
26
|
+
CopilotRuntimeError,
|
|
27
|
+
RoleNotFoundError,
|
|
28
|
+
ToolsNotSupportedError,
|
|
29
|
+
TriageError,
|
|
30
|
+
)
|
|
31
|
+
from asfops.fleet.roles import REGISTRY, RoleRegistry, RoleSpec
|
|
32
|
+
from asfops.fleet.schemas import (
|
|
33
|
+
AgentReport,
|
|
34
|
+
Finding,
|
|
35
|
+
RoleSelection,
|
|
36
|
+
Severity,
|
|
37
|
+
SynthesisSummary,
|
|
38
|
+
TriageDecision,
|
|
39
|
+
)
|
|
40
|
+
from asfops.models import CopilotBridge, CopilotModel, ModelRef, resolve_model, shutdown
|
|
41
|
+
from asfops.results import (
|
|
42
|
+
AgentResult,
|
|
43
|
+
AgentUsage,
|
|
44
|
+
FleetEvent,
|
|
45
|
+
FleetMetadata,
|
|
46
|
+
FleetResult,
|
|
47
|
+
ModelUsageTotals,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"REGISTRY",
|
|
52
|
+
"AgentReport",
|
|
53
|
+
"AgentResult",
|
|
54
|
+
"AgentUsage",
|
|
55
|
+
"AsfopsError",
|
|
56
|
+
"CopilotBridge",
|
|
57
|
+
"CopilotModel",
|
|
58
|
+
"CopilotRuntimeError",
|
|
59
|
+
"Finding",
|
|
60
|
+
"Fleet",
|
|
61
|
+
"FleetConfig",
|
|
62
|
+
"FleetEvent",
|
|
63
|
+
"FleetMetadata",
|
|
64
|
+
"FleetResult",
|
|
65
|
+
"ModelRef",
|
|
66
|
+
"ModelUsageTotals",
|
|
67
|
+
"RoleNotFoundError",
|
|
68
|
+
"RoleRegistry",
|
|
69
|
+
"RoleSelection",
|
|
70
|
+
"RoleSpec",
|
|
71
|
+
"Severity",
|
|
72
|
+
"SynthesisSummary",
|
|
73
|
+
"ToolsNotSupportedError",
|
|
74
|
+
"TriageDecision",
|
|
75
|
+
"TriageError",
|
|
76
|
+
"__version__",
|
|
77
|
+
"assess",
|
|
78
|
+
"assess_sync",
|
|
79
|
+
"compose_report",
|
|
80
|
+
"list_roles",
|
|
81
|
+
"resolve_model",
|
|
82
|
+
"shutdown",
|
|
83
|
+
]
|
asfops/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
asfops/api.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The public asfops API: the Fleet and module-level conveniences.
|
|
2
|
+
|
|
3
|
+
Designed as the interface an LLM tool-caller would want: one free-text
|
|
4
|
+
entrypoint (:meth:`Fleet.assess`), a discoverable roster
|
|
5
|
+
(:func:`list_roles`), a targeted single-expert call (:meth:`Fleet.run_role`),
|
|
6
|
+
and a fully ``model_dump_json()``-able :class:`~asfops.results.FleetResult`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from asfops.config import FleetConfig
|
|
15
|
+
from asfops.fleet.roles import REGISTRY, RoleRegistry, RoleSpec
|
|
16
|
+
from asfops.fleet.schemas import RoleSelection, TriageDecision
|
|
17
|
+
from asfops.models.client import shutdown
|
|
18
|
+
from asfops.orchestrator import EventCallback, Orchestrator
|
|
19
|
+
from asfops.results import (
|
|
20
|
+
AgentResult,
|
|
21
|
+
FleetMetadata,
|
|
22
|
+
FleetResult,
|
|
23
|
+
aggregate_usage,
|
|
24
|
+
build_report_md,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Fleet:
|
|
29
|
+
"""The security fleet: triage, invoke specialists, synthesize a report."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self, config: FleetConfig | None = None, *, registry: RoleRegistry | None = None
|
|
33
|
+
) -> None:
|
|
34
|
+
self.config = config or FleetConfig()
|
|
35
|
+
self.registry = registry or REGISTRY
|
|
36
|
+
self._orchestrator = Orchestrator(self.config, registry=self.registry)
|
|
37
|
+
|
|
38
|
+
async def assess(self, request: str, *, on_event: EventCallback | None = None) -> FleetResult:
|
|
39
|
+
"""Run a full assessment: triage → fan-out → synthesis → report."""
|
|
40
|
+
return await self._orchestrator.run(request, on_event=on_event)
|
|
41
|
+
|
|
42
|
+
def assess_sync(self, request: str, *, on_event: EventCallback | None = None) -> FleetResult:
|
|
43
|
+
"""Synchronous wrapper around :meth:`assess`.
|
|
44
|
+
|
|
45
|
+
Raises if called from within a running event loop (e.g. Jupyter);
|
|
46
|
+
``await fleet.assess(...)`` there instead.
|
|
47
|
+
"""
|
|
48
|
+
_guard_no_running_loop()
|
|
49
|
+
return asyncio.run(self.assess(request, on_event=on_event))
|
|
50
|
+
|
|
51
|
+
async def run_role(self, slug: str, request: str) -> AgentResult:
|
|
52
|
+
"""Engage a single specialist by slug (no triage, no synthesis)."""
|
|
53
|
+
self.registry.get(slug) # validates slug, raises RoleNotFoundError
|
|
54
|
+
sel = RoleSelection(slug=slug, rationale="Directly requested.", priority="primary")
|
|
55
|
+
return await self._orchestrator._run_role(sel, request, on_event=None)
|
|
56
|
+
|
|
57
|
+
def run_role_sync(self, slug: str, request: str) -> AgentResult:
|
|
58
|
+
_guard_no_running_loop()
|
|
59
|
+
return asyncio.run(self.run_role(slug, request))
|
|
60
|
+
|
|
61
|
+
def roster(self) -> tuple[RoleSpec, ...]:
|
|
62
|
+
"""The specialists available to this fleet."""
|
|
63
|
+
return self.registry.all()
|
|
64
|
+
|
|
65
|
+
async def aclose(self) -> None:
|
|
66
|
+
"""Shut down the shared Copilot runtime, if one was started."""
|
|
67
|
+
await shutdown()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _guard_no_running_loop() -> None:
|
|
71
|
+
try:
|
|
72
|
+
asyncio.get_running_loop()
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
return
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
"assess_sync() cannot be called from a running event loop; "
|
|
77
|
+
"use `await fleet.assess(...)` instead."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def assess(request: str, *, config: FleetConfig | None = None) -> FleetResult:
|
|
82
|
+
"""Assess a request with a default (or supplied) fleet configuration."""
|
|
83
|
+
return await Fleet(config).assess(request)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def assess_sync(request: str, *, config: FleetConfig | None = None) -> FleetResult:
|
|
87
|
+
"""Synchronous convenience wrapper around :func:`assess`."""
|
|
88
|
+
return Fleet(config).assess_sync(request)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_roles(*, registry: RoleRegistry | None = None) -> tuple[RoleSpec, ...]:
|
|
92
|
+
"""The full security-department roster."""
|
|
93
|
+
return (registry or REGISTRY).all()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compose_report(
|
|
97
|
+
request: str,
|
|
98
|
+
triage: TriageDecision,
|
|
99
|
+
agent_results: list[AgentResult],
|
|
100
|
+
*,
|
|
101
|
+
include_metadata: bool = True,
|
|
102
|
+
) -> FleetResult:
|
|
103
|
+
"""Assemble a :class:`FleetResult` from pre-computed pieces (no synthesis).
|
|
104
|
+
|
|
105
|
+
Useful when a caller has already run roles and just wants the composed
|
|
106
|
+
markdown report and usage rollup.
|
|
107
|
+
"""
|
|
108
|
+
now = datetime.now(UTC)
|
|
109
|
+
metadata: FleetMetadata | None = None
|
|
110
|
+
if include_metadata:
|
|
111
|
+
per_agent = [r.usage for r in agent_results]
|
|
112
|
+
totals, grand = aggregate_usage(per_agent)
|
|
113
|
+
metadata = FleetMetadata(
|
|
114
|
+
per_agent=per_agent,
|
|
115
|
+
totals_by_model=totals,
|
|
116
|
+
grand_total=grand,
|
|
117
|
+
started_at=now,
|
|
118
|
+
finished_at=now,
|
|
119
|
+
)
|
|
120
|
+
report_md = build_report_md(request, triage, agent_results, None, metadata)
|
|
121
|
+
return FleetResult(
|
|
122
|
+
request=request,
|
|
123
|
+
triage=triage,
|
|
124
|
+
agent_results=agent_results,
|
|
125
|
+
synthesis=None,
|
|
126
|
+
report_md=report_md,
|
|
127
|
+
metadata=metadata,
|
|
128
|
+
)
|
asfops/cli/__init__.py
ADDED
asfops/cli/app.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""The asfops command-line interface (typer + rich)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
|
|
15
|
+
from asfops._version import __version__
|
|
16
|
+
from asfops.api import Fleet
|
|
17
|
+
from asfops.cli.render import ProgressReporter, metadata_table, roster_table
|
|
18
|
+
from asfops.config import FleetConfig
|
|
19
|
+
from asfops.exceptions import AsfopsError, RoleNotFoundError
|
|
20
|
+
from asfops.results import AgentResult, FleetResult, build_agent_report_md
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="asfops",
|
|
24
|
+
help="Agentic Security Fleet Ops — a security department of LLM agents.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_stdout = Console()
|
|
30
|
+
_stderr = Console(stderr=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OutputFormat(StrEnum):
|
|
34
|
+
md = "md"
|
|
35
|
+
json = "json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RosterFormat(StrEnum):
|
|
39
|
+
table = "table"
|
|
40
|
+
json = "json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_request(request: str | None, file: Path | None) -> str:
|
|
44
|
+
if file is not None:
|
|
45
|
+
return file.read_text(encoding="utf-8")
|
|
46
|
+
if request in (None, "-"):
|
|
47
|
+
data = sys.stdin.read()
|
|
48
|
+
if not data.strip():
|
|
49
|
+
_fail("No request provided (empty stdin).")
|
|
50
|
+
return data
|
|
51
|
+
assert request is not None
|
|
52
|
+
return request
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fail(message: str, code: int = 1) -> None:
|
|
56
|
+
_stderr.print(f"[red]error:[/red] {message}")
|
|
57
|
+
raise typer.Exit(code)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _assess_and_close(fleet: Fleet, text: str, reporter: object) -> FleetResult:
|
|
61
|
+
# Run the assessment and shut the shared Copilot client down inside the SAME
|
|
62
|
+
# event loop — stopping it from a second asyncio.run() fails (loop closed).
|
|
63
|
+
try:
|
|
64
|
+
return await fleet.assess(text, on_event=reporter) # type: ignore[arg-type]
|
|
65
|
+
finally:
|
|
66
|
+
await fleet.aclose()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _run_role_and_close(fleet: Fleet, slug: str, text: str) -> AgentResult:
|
|
70
|
+
try:
|
|
71
|
+
return await fleet.run_role(slug, text)
|
|
72
|
+
finally:
|
|
73
|
+
await fleet.aclose()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_config(
|
|
77
|
+
model: str | None,
|
|
78
|
+
triage_model: str | None,
|
|
79
|
+
roles: list[str] | None,
|
|
80
|
+
exclude: list[str] | None,
|
|
81
|
+
concurrency: int | None,
|
|
82
|
+
timeout: float | None,
|
|
83
|
+
no_metadata: bool,
|
|
84
|
+
) -> FleetConfig:
|
|
85
|
+
cfg = FleetConfig()
|
|
86
|
+
if model:
|
|
87
|
+
cfg.default_model = model
|
|
88
|
+
if triage_model:
|
|
89
|
+
cfg.triage_model = triage_model
|
|
90
|
+
if roles:
|
|
91
|
+
cfg.force_roles = tuple(roles)
|
|
92
|
+
if exclude:
|
|
93
|
+
cfg.exclude_roles = tuple(exclude)
|
|
94
|
+
if concurrency:
|
|
95
|
+
cfg.max_concurrency = concurrency
|
|
96
|
+
if timeout:
|
|
97
|
+
cfg.per_agent_timeout_s = timeout
|
|
98
|
+
cfg.include_metadata = not no_metadata
|
|
99
|
+
return cfg
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def assess(
|
|
104
|
+
request: Annotated[
|
|
105
|
+
str | None, typer.Argument(help="The assessment request text, or '-' for stdin.")
|
|
106
|
+
] = None,
|
|
107
|
+
file: Annotated[
|
|
108
|
+
Path | None, typer.Option("--file", "-f", help="Read the request from a file.")
|
|
109
|
+
] = None,
|
|
110
|
+
model: Annotated[
|
|
111
|
+
str | None, typer.Option("--model", "-m", help="Default model ref for all agents.")
|
|
112
|
+
] = None,
|
|
113
|
+
triage_model: Annotated[
|
|
114
|
+
str | None, typer.Option("--triage-model", help="Model ref for the triage step.")
|
|
115
|
+
] = None,
|
|
116
|
+
role: Annotated[
|
|
117
|
+
list[str] | None, typer.Option("--role", "-r", help="Force a role (repeatable).")
|
|
118
|
+
] = None,
|
|
119
|
+
exclude: Annotated[
|
|
120
|
+
list[str] | None, typer.Option("--exclude", "-x", help="Exclude a role (repeatable).")
|
|
121
|
+
] = None,
|
|
122
|
+
concurrency: Annotated[
|
|
123
|
+
int | None, typer.Option("--concurrency", "-c", help="Max concurrent agents.")
|
|
124
|
+
] = None,
|
|
125
|
+
timeout: Annotated[
|
|
126
|
+
float | None, typer.Option("--timeout", help="Per-agent timeout (seconds).")
|
|
127
|
+
] = None,
|
|
128
|
+
output_format: Annotated[
|
|
129
|
+
OutputFormat, typer.Option("--format", help="Output format.")
|
|
130
|
+
] = OutputFormat.md,
|
|
131
|
+
output: Annotated[
|
|
132
|
+
Path | None, typer.Option("--output", "-o", help="Write output to a file.")
|
|
133
|
+
] = None,
|
|
134
|
+
no_metadata: Annotated[
|
|
135
|
+
bool, typer.Option("--no-metadata", help="Omit usage metadata.")
|
|
136
|
+
] = False,
|
|
137
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress progress output.")] = False,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Run a full fleet assessment and print the composed report."""
|
|
140
|
+
text = _read_request(request, file)
|
|
141
|
+
cfg = _build_config(model, triage_model, role, exclude, concurrency, timeout, no_metadata)
|
|
142
|
+
fleet = Fleet(cfg)
|
|
143
|
+
|
|
144
|
+
show_progress = not quiet and output_format is OutputFormat.md and output is None
|
|
145
|
+
reporter = ProgressReporter(_stderr) if show_progress else None
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = asyncio.run(_assess_and_close(fleet, text, reporter))
|
|
149
|
+
except AsfopsError as exc:
|
|
150
|
+
_fail(str(exc))
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if output_format is OutputFormat.json:
|
|
154
|
+
payload = result.model_dump_json(indent=2)
|
|
155
|
+
_emit(payload, output)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if output is not None:
|
|
159
|
+
_emit(result.report_md, output)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
_stdout.print(Markdown(result.report_md))
|
|
163
|
+
table = metadata_table(result)
|
|
164
|
+
if table is not None and not no_metadata:
|
|
165
|
+
_stdout.print(table)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def run(
|
|
170
|
+
slug: Annotated[str, typer.Argument(help="Role slug to engage (see `asfops roster`).")],
|
|
171
|
+
request: Annotated[
|
|
172
|
+
str | None, typer.Argument(help="The request text, or '-' for stdin.")
|
|
173
|
+
] = None,
|
|
174
|
+
file: Annotated[Path | None, typer.Option("--file", "-f")] = None,
|
|
175
|
+
model: Annotated[str | None, typer.Option("--model", "-m")] = None,
|
|
176
|
+
output_format: Annotated[
|
|
177
|
+
OutputFormat, typer.Option("--format", help="Output format.")
|
|
178
|
+
] = OutputFormat.md,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Engage a single specialist directly (no triage, no synthesis)."""
|
|
181
|
+
text = _read_request(request, file)
|
|
182
|
+
cfg = FleetConfig()
|
|
183
|
+
if model:
|
|
184
|
+
cfg.default_model = model
|
|
185
|
+
fleet = Fleet(cfg)
|
|
186
|
+
try:
|
|
187
|
+
result = asyncio.run(_run_role_and_close(fleet, slug, text))
|
|
188
|
+
except RoleNotFoundError as exc:
|
|
189
|
+
_fail(str(exc), code=2)
|
|
190
|
+
return
|
|
191
|
+
except AsfopsError as exc:
|
|
192
|
+
_fail(str(exc))
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
if output_format is OutputFormat.json:
|
|
196
|
+
_stdout.print_json(result.model_dump_json())
|
|
197
|
+
return
|
|
198
|
+
if result.report is None:
|
|
199
|
+
_fail(f"{slug} failed: {result.error}")
|
|
200
|
+
return
|
|
201
|
+
_stdout.print(Markdown(build_agent_report_md(result.role_name, result.report)))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
def roster(
|
|
206
|
+
output_format: Annotated[
|
|
207
|
+
RosterFormat, typer.Option("--format", help="Output format.")
|
|
208
|
+
] = RosterFormat.table,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""List the security specialists in the fleet."""
|
|
211
|
+
roles = Fleet().roster()
|
|
212
|
+
if output_format is RosterFormat.json:
|
|
213
|
+
import json
|
|
214
|
+
|
|
215
|
+
_stdout.print_json(
|
|
216
|
+
json.dumps(
|
|
217
|
+
[
|
|
218
|
+
{"slug": r.slug, "name": r.name, "charter": r.charter, "tags": list(r.tags)}
|
|
219
|
+
for r in roles
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
_stdout.print(roster_table(roles))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def models() -> None:
|
|
229
|
+
"""Check GitHub Copilot availability and list available models."""
|
|
230
|
+
try:
|
|
231
|
+
from copilot import CopilotClient
|
|
232
|
+
except ImportError:
|
|
233
|
+
_fail("github-copilot-sdk is not installed.")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
async def _list() -> list[str]:
|
|
237
|
+
client = CopilotClient(log_level="error")
|
|
238
|
+
await client.start()
|
|
239
|
+
try:
|
|
240
|
+
infos = await client.list_models()
|
|
241
|
+
return [getattr(m, "id", str(m)) for m in infos]
|
|
242
|
+
finally:
|
|
243
|
+
await client.stop()
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
ids = asyncio.run(_list())
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
_stderr.print(f"[yellow]Copilot runtime unavailable:[/yellow] {exc}")
|
|
249
|
+
_stdout.print(
|
|
250
|
+
"You can still run the fleet on any pydantic-ai model, e.g. "
|
|
251
|
+
"[cyan]--model openai:gpt-5.2[/cyan] or "
|
|
252
|
+
"[cyan]--model anthropic:claude-sonnet-4-5[/cyan]."
|
|
253
|
+
)
|
|
254
|
+
raise typer.Exit(0) from None
|
|
255
|
+
_stdout.print("[green]Copilot runtime OK.[/green] Available models:")
|
|
256
|
+
for mid in ids:
|
|
257
|
+
_stdout.print(f" • copilot:{mid}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@app.command()
|
|
261
|
+
def version() -> None:
|
|
262
|
+
"""Print the asfops version."""
|
|
263
|
+
_stdout.print(__version__)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _emit(content: str, output: Path | None) -> None:
|
|
267
|
+
if output is not None:
|
|
268
|
+
output.write_text(content, encoding="utf-8")
|
|
269
|
+
_stderr.print(f"[green]Wrote[/green] {output}")
|
|
270
|
+
else:
|
|
271
|
+
# plain stdout, no rich formatting (pipe-friendly)
|
|
272
|
+
sys.stdout.write(content)
|
|
273
|
+
if not content.endswith("\n"):
|
|
274
|
+
sys.stdout.write("\n")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main() -> None:
|
|
278
|
+
app()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
main()
|
asfops/cli/render.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Rich rendering helpers for the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from asfops.fleet.roles import RoleSpec
|
|
11
|
+
from asfops.results import FleetEvent, FleetResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def roster_table(roles: tuple[RoleSpec, ...]) -> Table:
|
|
15
|
+
table = Table(title="Security Fleet Roster", show_lines=False, expand=True)
|
|
16
|
+
table.add_column("Slug", style="cyan", no_wrap=True)
|
|
17
|
+
table.add_column("Role", style="bold")
|
|
18
|
+
table.add_column("Charter")
|
|
19
|
+
for role in roles:
|
|
20
|
+
table.add_row(role.slug, role.name, role.charter)
|
|
21
|
+
return table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def metadata_table(result: FleetResult) -> Table | None:
|
|
25
|
+
if result.metadata is None:
|
|
26
|
+
return None
|
|
27
|
+
table = Table(title="Usage by Model", expand=True)
|
|
28
|
+
table.add_column("Model", style="cyan")
|
|
29
|
+
table.add_column("Requests", justify="right")
|
|
30
|
+
table.add_column("Input", justify="right")
|
|
31
|
+
table.add_column("Output", justify="right")
|
|
32
|
+
for t in result.metadata.totals_by_model:
|
|
33
|
+
table.add_row(t.model_id, str(t.requests), str(t.input_tokens), str(t.output_tokens))
|
|
34
|
+
g = result.metadata.grand_total
|
|
35
|
+
table.add_row(
|
|
36
|
+
"[bold]all[/bold]",
|
|
37
|
+
f"[bold]{g.requests}[/bold]",
|
|
38
|
+
f"[bold]{g.input_tokens}[/bold]",
|
|
39
|
+
f"[bold]{g.output_tokens}[/bold]",
|
|
40
|
+
)
|
|
41
|
+
return table
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProgressReporter:
|
|
45
|
+
"""Renders live per-role progress from fleet events.
|
|
46
|
+
|
|
47
|
+
Kept deliberately simple (line-based, thread-safe) so it composes with any
|
|
48
|
+
console and needs no live-refresh teardown.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, console: Console) -> None:
|
|
52
|
+
self.console = console
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def __call__(self, event: FleetEvent) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._render(event)
|
|
58
|
+
|
|
59
|
+
def _render(self, event: FleetEvent) -> None:
|
|
60
|
+
c = self.console
|
|
61
|
+
match event.kind:
|
|
62
|
+
case "triage_started":
|
|
63
|
+
c.print("[dim]Triaging request…[/dim]")
|
|
64
|
+
case "triage_finished":
|
|
65
|
+
c.print(f"[green]Triage selected:[/green] {event.detail}")
|
|
66
|
+
case "agent_started":
|
|
67
|
+
c.print(f" [yellow]▶ {event.slug}[/yellow] running…")
|
|
68
|
+
case "agent_finished":
|
|
69
|
+
c.print(f" [green]✓ {event.slug}[/green] done")
|
|
70
|
+
case "agent_failed":
|
|
71
|
+
c.print(f" [red]✗ {event.slug}[/red] failed: {event.detail}")
|
|
72
|
+
case "synthesis_started":
|
|
73
|
+
c.print("[dim]Synthesizing report…[/dim]")
|
|
74
|
+
case "synthesis_finished":
|
|
75
|
+
detail = f" ({event.detail})" if event.detail else ""
|
|
76
|
+
c.print(f"[green]Synthesis complete[/green]{detail}")
|
asfops/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Fleet configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from asfops.models.resolve import ModelRef
|
|
9
|
+
|
|
10
|
+
CopilotMode = Literal["model", "bridge"]
|
|
11
|
+
|
|
12
|
+
DEFAULT_MODEL: ModelRef = "copilot:claude-sonnet-4.5"
|
|
13
|
+
DEFAULT_TRIAGE_FALLBACK: tuple[str, ...] = ("security-architect", "threat-model", "appsec")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FleetConfig:
|
|
18
|
+
"""Configuration for a :class:`~asfops.api.Fleet` run.
|
|
19
|
+
|
|
20
|
+
Any :class:`~asfops.models.resolve.ModelRef` is accepted for a model: a
|
|
21
|
+
``"copilot:<name>"`` string, a native pydantic-ai model string
|
|
22
|
+
(``"openai:gpt-5.2"``, ``"anthropic:claude-sonnet-4-5"``, ``"test"``), or a
|
|
23
|
+
``Model`` instance.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
default_model: ModelRef = DEFAULT_MODEL
|
|
27
|
+
triage_model: ModelRef | None = None
|
|
28
|
+
"""Model used for triage; falls back to ``default_model``."""
|
|
29
|
+
synthesis_model: ModelRef | None = None
|
|
30
|
+
"""Model used for synthesis; falls back to ``default_model``."""
|
|
31
|
+
model_overrides: dict[str, ModelRef] = field(default_factory=dict)
|
|
32
|
+
"""Per-role-slug model overrides."""
|
|
33
|
+
|
|
34
|
+
max_concurrency: int = 5
|
|
35
|
+
per_agent_timeout_s: float = 300.0
|
|
36
|
+
|
|
37
|
+
force_roles: tuple[str, ...] = ()
|
|
38
|
+
"""Roles always engaged, regardless of triage."""
|
|
39
|
+
exclude_roles: tuple[str, ...] = ()
|
|
40
|
+
"""Roles never engaged, even if triage or force selects them."""
|
|
41
|
+
|
|
42
|
+
include_metadata: bool = True
|
|
43
|
+
copilot_mode: CopilotMode = "model"
|
|
44
|
+
"""Reserved: ``"bridge"`` routes Copilot agents through the manual bridge."""
|
|
45
|
+
|
|
46
|
+
on_empty_triage: Literal["fallback", "error"] = "fallback"
|
|
47
|
+
"""When triage selects nothing usable: engage a default core set, or raise."""
|
|
48
|
+
|
|
49
|
+
def triage_model_ref(self) -> ModelRef:
|
|
50
|
+
return self.triage_model if self.triage_model is not None else self.default_model
|
|
51
|
+
|
|
52
|
+
def synthesis_model_ref(self) -> ModelRef:
|
|
53
|
+
return self.synthesis_model if self.synthesis_model is not None else self.default_model
|
|
54
|
+
|
|
55
|
+
def model_for_role(self, slug: str) -> ModelRef:
|
|
56
|
+
return self.model_overrides.get(slug, self.default_model)
|
asfops/exceptions.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exception hierarchy for asfops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AsfopsError(Exception):
|
|
7
|
+
"""Base class for all asfops errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CopilotRuntimeError(AsfopsError):
|
|
11
|
+
"""The Copilot runtime failed to start, respond, or complete a turn."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolsNotSupportedError(AsfopsError):
|
|
15
|
+
"""Raised when pydantic-ai function tools are requested on a CopilotModel.
|
|
16
|
+
|
|
17
|
+
The Copilot runtime executes tools inside its own turn and cannot return
|
|
18
|
+
tool calls to pydantic-ai. Use an API-key model (e.g. ``openai:...`` or
|
|
19
|
+
``anthropic:...``) for agents that need custom tools.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RoleNotFoundError(AsfopsError):
|
|
24
|
+
"""Raised when a role slug is not present in the registry."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, slug: str, known: tuple[str, ...]) -> None:
|
|
27
|
+
self.slug = slug
|
|
28
|
+
self.known = known
|
|
29
|
+
super().__init__(f"Unknown role slug {slug!r}. Known roles: {', '.join(known)}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TriageError(AsfopsError):
|
|
33
|
+
"""Raised when triage produces no usable role selection."""
|
asfops/fleet/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Fleet: security-department roles, schemas, and agent construction."""
|
|
2
|
+
|
|
3
|
+
from asfops.fleet.member import build_agent
|
|
4
|
+
from asfops.fleet.roles import REGISTRY, RoleRegistry, RoleSpec
|
|
5
|
+
from asfops.fleet.roster import register_default_roles
|
|
6
|
+
from asfops.fleet.schemas import (
|
|
7
|
+
SEVERITY_ORDER,
|
|
8
|
+
AgentReport,
|
|
9
|
+
Confidence,
|
|
10
|
+
Finding,
|
|
11
|
+
RoleSelection,
|
|
12
|
+
Severity,
|
|
13
|
+
SynthesisSummary,
|
|
14
|
+
TriageDecision,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"REGISTRY",
|
|
19
|
+
"SEVERITY_ORDER",
|
|
20
|
+
"AgentReport",
|
|
21
|
+
"Confidence",
|
|
22
|
+
"Finding",
|
|
23
|
+
"RoleRegistry",
|
|
24
|
+
"RoleSelection",
|
|
25
|
+
"RoleSpec",
|
|
26
|
+
"Severity",
|
|
27
|
+
"SynthesisSummary",
|
|
28
|
+
"TriageDecision",
|
|
29
|
+
"build_agent",
|
|
30
|
+
"register_default_roles",
|
|
31
|
+
]
|