agentlinter 0.1.1__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.
agent_lint/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """agent-lint -- Estimate costs and lint agent workflow YAML files."""
2
+
3
+ __version__ = "0.1.1"
4
+ __all__ = ["__version__"]
agent_lint/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow running as ``python -m agent_lint``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_lint.cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
agent_lint/cli.py ADDED
@@ -0,0 +1,215 @@
1
+ """CLI entry point for agent-lint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from agent_lint import __version__
11
+ from agent_lint.exceptions import AgentAuditError
12
+ from agent_lint.formatters import (
13
+ format_compare_json,
14
+ format_compare_table,
15
+ format_estimate_json,
16
+ format_estimate_markdown,
17
+ format_estimate_table,
18
+ format_lint_json,
19
+ format_lint_markdown,
20
+ format_lint_table,
21
+ )
22
+ from agent_lint.licensing import get_upgrade_message, has_feature
23
+
24
+ app = typer.Typer(
25
+ name="agent-lint",
26
+ help="Analyze agent workflow configs for cost estimation and anti-patterns.",
27
+ )
28
+ console = Console()
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Root callback
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @app.callback(invoke_without_command=True)
37
+ def main(
38
+ ctx: typer.Context,
39
+ version: bool = typer.Option(
40
+ False,
41
+ "--version",
42
+ "-V",
43
+ is_eager=True,
44
+ help="Show version and exit.",
45
+ ),
46
+ ) -> None:
47
+ """Analyze agent workflow configs for cost estimation and anti-patterns."""
48
+ if version:
49
+ console.print(f"agent-lint {__version__}")
50
+ raise typer.Exit()
51
+ if ctx.invoked_subcommand is None:
52
+ console.print(ctx.get_help())
53
+ raise typer.Exit()
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # estimate
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ @app.command()
62
+ def estimate(
63
+ workflow_file: Path = typer.Argument(..., help="Workflow YAML file to analyze."),
64
+ provider: str | None = typer.Option(
65
+ None, "--provider", "-p", help="Provider (anthropic, openai, ollama)."
66
+ ),
67
+ model: str | None = typer.Option(None, "--model", "-m", help="Model name."),
68
+ json: bool = typer.Option(False, "--json", help="Output as JSON."),
69
+ fmt: str = typer.Option("table", "--format", "-f", help="Output format (table|json|markdown)."),
70
+ ) -> None:
71
+ """Estimate token usage and cost for a workflow."""
72
+ from agent_lint.estimator import estimate_workflow
73
+ from agent_lint.parsers import parse_workflow
74
+
75
+ try:
76
+ wf = parse_workflow(workflow_file)
77
+ result = estimate_workflow(wf, provider=provider, model=model)
78
+ except AgentAuditError as exc:
79
+ console.print(f"[red]Error:[/red] {exc}")
80
+ raise typer.Exit(1) from exc
81
+
82
+ if json or fmt == "json":
83
+ format_estimate_json(result, console)
84
+ elif fmt == "markdown":
85
+ format_estimate_markdown(result, console)
86
+ else:
87
+ format_estimate_table(result, console)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # lint
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ @app.command()
96
+ def lint(
97
+ workflow_file: Path = typer.Argument(..., help="Workflow YAML file to lint."),
98
+ category: str | None = typer.Option(
99
+ None,
100
+ "--category",
101
+ "-c",
102
+ help="Filter by category (budget|resilience|efficiency|security).",
103
+ ),
104
+ severity: str | None = typer.Option(
105
+ None,
106
+ "--severity",
107
+ "-s",
108
+ help="Filter by severity (error|warning|info).",
109
+ ),
110
+ fail_under: int | None = typer.Option(
111
+ None, "--fail-under", help="Exit 1 if score is below this threshold."
112
+ ),
113
+ json: bool = typer.Option(False, "--json", help="Output as JSON."),
114
+ fmt: str = typer.Option("table", "--format", "-f", help="Output format (table|json|markdown)."),
115
+ ) -> None:
116
+ """Lint a workflow for anti-patterns and best practice violations."""
117
+ from agent_lint.linter import run_lint
118
+ from agent_lint.models import RuleCategory, Severity
119
+ from agent_lint.parsers import parse_workflow
120
+
121
+ try:
122
+ wf = parse_workflow(workflow_file)
123
+ except AgentAuditError as exc:
124
+ console.print(f"[red]Error:[/red] {exc}")
125
+ raise typer.Exit(1) from exc
126
+
127
+ cat = None
128
+ if category:
129
+ try:
130
+ cat = RuleCategory(category.lower())
131
+ except ValueError:
132
+ console.print(f"[red]Unknown category:[/red] {category}")
133
+ raise typer.Exit(1) from None
134
+
135
+ sev = None
136
+ if severity:
137
+ try:
138
+ sev = Severity(severity.lower())
139
+ except ValueError:
140
+ console.print(f"[red]Unknown severity:[/red] {severity}")
141
+ raise typer.Exit(1) from None
142
+
143
+ report = run_lint(wf, category=cat, severity=sev)
144
+
145
+ if json or fmt == "json":
146
+ format_lint_json(report, console)
147
+ elif fmt == "markdown":
148
+ format_lint_markdown(report, console)
149
+ else:
150
+ format_lint_table(report, console)
151
+
152
+ if fail_under is not None and report.score < fail_under:
153
+ console.print(f"[red]Score {report.score} is below threshold {fail_under}.[/red]")
154
+ raise typer.Exit(1)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # status
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ @app.command()
163
+ def status() -> None:
164
+ """Show license status and available features."""
165
+ from agent_lint.licensing import TIER_DEFINITIONS, get_license_info
166
+
167
+ info = get_license_info()
168
+ tier_config = TIER_DEFINITIONS[info.tier]
169
+
170
+ console.print(f"\n[bold]agent-lint {__version__}[/bold]")
171
+ console.print(f"[bold]Tier:[/bold] {tier_config.name} ({tier_config.price_label})")
172
+
173
+ if info.license_key:
174
+ masked = info.license_key[:9] + "****-****"
175
+ console.print(f"[bold]Key:[/bold] {masked}")
176
+ valid_str = "[green]valid[/green]" if info.valid else "[red]invalid[/red]"
177
+ console.print(f"[bold]Valid:[/bold] {valid_str}")
178
+
179
+ console.print(f"\n[bold]Features:[/bold] {', '.join(tier_config.features)}")
180
+ console.print()
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # compare (Pro)
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ @app.command()
189
+ def compare(
190
+ workflow_file: Path = typer.Argument(..., help="Workflow YAML file to analyze."),
191
+ providers: list[str] | None = typer.Option(
192
+ None, "--provider", "-p", help="Providers to compare (repeat for each)."
193
+ ),
194
+ json: bool = typer.Option(False, "--json", help="Output as JSON."),
195
+ ) -> None:
196
+ """Compare workflow costs across providers (Pro feature)."""
197
+ from agent_lint.comparator import compare_providers
198
+ from agent_lint.parsers import parse_workflow
199
+
200
+ # Gate check.
201
+ if not has_feature("compare"):
202
+ console.print(f"[yellow]{get_upgrade_message('compare')}[/yellow]")
203
+ raise typer.Exit(1)
204
+
205
+ try:
206
+ wf = parse_workflow(workflow_file)
207
+ result = compare_providers(wf, providers=providers)
208
+ except AgentAuditError as exc:
209
+ console.print(f"[red]Error:[/red] {exc}")
210
+ raise typer.Exit(1) from exc
211
+
212
+ if json:
213
+ format_compare_json(result, console)
214
+ else:
215
+ format_compare_table(result, console)
@@ -0,0 +1,47 @@
1
+ """Multi-provider cost comparison engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_lint.estimator import estimate_workflow
6
+ from agent_lint.models import CompareResult, ParsedWorkflow, WorkflowEstimate
7
+ from agent_lint.pricing import list_providers, load_providers
8
+
9
+
10
+ def compare_providers(
11
+ workflow: ParsedWorkflow,
12
+ providers: list[str] | None = None,
13
+ ) -> CompareResult:
14
+ """Estimate workflow cost across multiple providers and compare."""
15
+ all_providers = load_providers()
16
+
17
+ if providers is None:
18
+ providers = list_providers(providers=all_providers)
19
+
20
+ estimates: list[WorkflowEstimate] = []
21
+ for provider in providers:
22
+ est = estimate_workflow(workflow, provider=provider)
23
+ estimates.append(est)
24
+
25
+ if not estimates:
26
+ return CompareResult(
27
+ workflow_name=workflow.name,
28
+ estimates=[],
29
+ cheapest="",
30
+ most_expensive="",
31
+ savings_pct=0.0,
32
+ )
33
+
34
+ cheapest = min(estimates, key=lambda e: e.total_cost_usd)
35
+ most_expensive = max(estimates, key=lambda e: e.total_cost_usd)
36
+
37
+ savings = 0.0
38
+ if most_expensive.total_cost_usd > 0:
39
+ savings = round((1 - cheapest.total_cost_usd / most_expensive.total_cost_usd) * 100, 1)
40
+
41
+ return CompareResult(
42
+ workflow_name=workflow.name,
43
+ estimates=estimates,
44
+ cheapest=cheapest.provider,
45
+ most_expensive=most_expensive.provider,
46
+ savings_pct=savings,
47
+ )
agent_lint/config.py ADDED
@@ -0,0 +1,83 @@
1
+ """Configuration constants and defaults for agent-lint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Token defaults by agent role (when estimated_tokens not declared).
9
+ # ---------------------------------------------------------------------------
10
+ ROLE_TOKEN_DEFAULTS: dict[str, int] = {
11
+ "planner": 5000,
12
+ "builder": 20000,
13
+ "tester": 10000,
14
+ "reviewer": 5000,
15
+ "architect": 8000,
16
+ "documenter": 6000,
17
+ "analyst": 8000,
18
+ "reporter": 4000,
19
+ "visualizer": 5000,
20
+ "security_auditor": 12000,
21
+ }
22
+
23
+ # Default tokens by step type when no role is specified.
24
+ STEP_TYPE_TOKEN_DEFAULTS: dict[str, int] = {
25
+ "llm": 8000,
26
+ "shell": 0,
27
+ "checkpoint": 0,
28
+ "mcp_tool": 0,
29
+ "fan_in": 0,
30
+ "branch": 0,
31
+ }
32
+
33
+ # Input/output token ratio (30% input, 70% output).
34
+ INPUT_OUTPUT_RATIO: float = 0.3
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Gorgon step type → provider mapping.
38
+ # ---------------------------------------------------------------------------
39
+ STEP_TYPE_PROVIDER_MAP: dict[str, str] = {
40
+ "claude_code": "anthropic",
41
+ "openai": "openai",
42
+ }
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Gorgon step types that are LLM-based (cost tokens).
46
+ # ---------------------------------------------------------------------------
47
+ LLM_STEP_TYPES: frozenset[str] = frozenset({"claude_code", "openai"})
48
+
49
+ # Non-LLM step types (zero token cost).
50
+ ZERO_COST_STEP_TYPES: frozenset[str] = frozenset(
51
+ {
52
+ "shell",
53
+ "checkpoint",
54
+ "mcp_tool",
55
+ "fan_in",
56
+ "branch",
57
+ }
58
+ )
59
+
60
+ # Container step types (sum nested steps).
61
+ CONTAINER_STEP_TYPES: frozenset[str] = frozenset(
62
+ {
63
+ "parallel",
64
+ "fan_out",
65
+ "map_reduce",
66
+ "loop",
67
+ }
68
+ )
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Lint scoring weights.
72
+ # ---------------------------------------------------------------------------
73
+ SEVERITY_DEDUCTIONS: dict[str, int] = {
74
+ "error": 10,
75
+ "warning": 5,
76
+ "info": 2,
77
+ }
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Paths.
81
+ # ---------------------------------------------------------------------------
82
+ DATA_DIR: Path = Path(__file__).parent / "data"
83
+ PROVIDERS_FILE: Path = DATA_DIR / "providers.yaml"
@@ -0,0 +1,54 @@
1
+ # Provider pricing database for agent-lint.
2
+ # Prices in USD per 1K tokens. Updated 2025-Q4.
3
+
4
+ providers:
5
+ anthropic:
6
+ default_model: claude-sonnet-4
7
+ models:
8
+ claude-opus-4:
9
+ input: 0.015
10
+ output: 0.075
11
+ context: 200000
12
+ claude-sonnet-4:
13
+ input: 0.003
14
+ output: 0.015
15
+ context: 200000
16
+ claude-haiku-3.5:
17
+ input: 0.0008
18
+ output: 0.004
19
+ context: 200000
20
+
21
+ openai:
22
+ default_model: gpt-4o
23
+ models:
24
+ gpt-4o:
25
+ input: 0.0025
26
+ output: 0.01
27
+ context: 128000
28
+ gpt-4o-mini:
29
+ input: 0.00015
30
+ output: 0.0006
31
+ context: 128000
32
+ o1:
33
+ input: 0.015
34
+ output: 0.06
35
+ context: 200000
36
+
37
+ ollama:
38
+ default_model: llama3.3-70b
39
+ models:
40
+ llama3.3-70b:
41
+ input: 0.0
42
+ output: 0.0
43
+ context: 131072
44
+ notes: "Self-hosted, hardware cost not included"
45
+ deepseek-r1:
46
+ input: 0.0
47
+ output: 0.0
48
+ context: 65536
49
+ notes: "Self-hosted"
50
+
51
+ # Map Gorgon step types to providers.
52
+ step_type_mapping:
53
+ claude_code: anthropic
54
+ openai: openai
@@ -0,0 +1,130 @@
1
+ """Token estimation engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_lint.config import (
6
+ INPUT_OUTPUT_RATIO,
7
+ ROLE_TOKEN_DEFAULTS,
8
+ STEP_TYPE_TOKEN_DEFAULTS,
9
+ )
10
+ from agent_lint.models import (
11
+ ParsedStep,
12
+ ParsedWorkflow,
13
+ StepEstimate,
14
+ StepType,
15
+ WorkflowEstimate,
16
+ )
17
+ from agent_lint.pricing import calculate_cost, get_model_pricing, load_providers
18
+
19
+
20
+ def _resolve_tokens(step: ParsedStep) -> tuple[int, str]:
21
+ """Resolve token estimate for a step. Returns (tokens, source)."""
22
+ # 1. Declared in YAML.
23
+ if step.estimated_tokens is not None:
24
+ return step.estimated_tokens, "declared"
25
+
26
+ # 2. Archetype default by role.
27
+ if step.role and step.role in ROLE_TOKEN_DEFAULTS:
28
+ return ROLE_TOKEN_DEFAULTS[step.role], "archetype"
29
+
30
+ # 3. Step type default.
31
+ default = STEP_TYPE_TOKEN_DEFAULTS.get(step.step_type.value, 0)
32
+ if step.step_type == StepType.LLM and default == 0:
33
+ default = STEP_TYPE_TOKEN_DEFAULTS.get("llm", 8000)
34
+ return default, "default"
35
+
36
+
37
+ def _split_tokens(total: int) -> tuple[int, int]:
38
+ """Split total tokens into input/output based on ratio."""
39
+ input_tokens = int(total * INPUT_OUTPUT_RATIO)
40
+ output_tokens = total - input_tokens
41
+ return input_tokens, output_tokens
42
+
43
+
44
+ def estimate_step(
45
+ step: ParsedStep,
46
+ provider: str,
47
+ model: str,
48
+ ) -> StepEstimate:
49
+ """Estimate tokens and cost for a single step."""
50
+ total_tokens, source = _resolve_tokens(step)
51
+
52
+ # Container steps: sum nested step tokens.
53
+ if step.nested_steps:
54
+ nested_total = sum(_resolve_tokens(ns)[0] for ns in step.nested_steps)
55
+ if nested_total > total_tokens:
56
+ total_tokens = nested_total
57
+ source = "declared" if step.estimated_tokens else "archetype"
58
+
59
+ input_tokens, output_tokens = _split_tokens(total_tokens)
60
+
61
+ # Non-LLM steps have zero cost regardless.
62
+ if step.step_type != StepType.LLM:
63
+ cost = 0.0
64
+ else:
65
+ pricing = get_model_pricing(provider, model)
66
+ cost = calculate_cost(input_tokens, output_tokens, pricing)
67
+
68
+ return StepEstimate(
69
+ step_id=step.id,
70
+ step_type=step.step_type,
71
+ provider=provider,
72
+ model=model,
73
+ role=step.role,
74
+ estimated_tokens=total_tokens,
75
+ input_tokens=input_tokens,
76
+ output_tokens=output_tokens,
77
+ cost_usd=cost,
78
+ source=source,
79
+ )
80
+
81
+
82
+ def estimate_workflow(
83
+ workflow: ParsedWorkflow,
84
+ provider: str | None = None,
85
+ model: str | None = None,
86
+ ) -> WorkflowEstimate:
87
+ """Estimate total tokens and cost for a workflow."""
88
+ providers = load_providers()
89
+
90
+ # Track whether the user explicitly set the provider.
91
+ explicit_provider = provider is not None
92
+
93
+ # Determine default provider from first LLM step.
94
+ if provider is None:
95
+ for step in workflow.steps:
96
+ if step.provider:
97
+ provider = step.provider
98
+ break
99
+ if provider is None:
100
+ provider = "anthropic"
101
+
102
+ # Determine model.
103
+ if model is None:
104
+ config = providers.get(provider)
105
+ model = config.default_model if config else "unknown"
106
+
107
+ step_estimates: list[StepEstimate] = []
108
+ for step in workflow.steps:
109
+ # Explicit CLI provider overrides step-level providers.
110
+ step_provider = provider if explicit_provider else step.provider or provider
111
+ step_model = step.model or model
112
+ step_estimates.append(estimate_step(step, step_provider, step_model))
113
+
114
+ total_tokens = sum(e.estimated_tokens for e in step_estimates)
115
+ total_cost = sum(e.cost_usd for e in step_estimates)
116
+
117
+ budget_util: float | None = None
118
+ if workflow.token_budget and workflow.token_budget > 0:
119
+ budget_util = round((total_tokens / workflow.token_budget) * 100, 1)
120
+
121
+ return WorkflowEstimate(
122
+ workflow_name=workflow.name,
123
+ total_tokens=total_tokens,
124
+ total_cost_usd=round(total_cost, 6),
125
+ budget_declared=workflow.token_budget,
126
+ budget_utilization=budget_util,
127
+ steps=step_estimates,
128
+ provider=provider,
129
+ model=model,
130
+ )
@@ -0,0 +1,27 @@
1
+ """Custom exceptions for agent-lint."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AgentAuditError(Exception):
7
+ """Base exception for all agent-lint errors."""
8
+
9
+
10
+ class ParseError(AgentAuditError):
11
+ """Workflow YAML parsing failed."""
12
+
13
+
14
+ class EstimateError(AgentAuditError):
15
+ """Cost estimation failed."""
16
+
17
+
18
+ class LintError(AgentAuditError):
19
+ """Linting failed unexpectedly."""
20
+
21
+
22
+ class PricingError(AgentAuditError):
23
+ """Pricing data load or calculation failed."""
24
+
25
+
26
+ class LicenseError(AgentAuditError):
27
+ """Feature requires a higher license tier."""