turnloop 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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """turnloop — a local-first agentic coding harness."""
2
+
3
+ __version__ = "0.1.0"
turnloop/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from turnloop.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,175 @@
1
+ """Wiring one configured agent together.
2
+
3
+ Every entry point — TUI, headless, experiments, subagents — goes through here, so
4
+ there is exactly one place where "what a session consists of" is defined. When the
5
+ TUI and the experiment runner disagree about setup, the measurements stop meaning
6
+ anything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import anyio
15
+
16
+ from turnloop.agent.loop import AgentLoop
17
+ from turnloop.agent.system_prompt import build_system
18
+ from turnloop.config import Settings
19
+ from turnloop.context.compaction import Compactor
20
+ from turnloop.mcp.client import MCPManager
21
+ from turnloop.permissions.engine import PermissionEngine
22
+ from turnloop.providers.base import Provider
23
+ from turnloop.providers.registry import build_provider
24
+ from turnloop.sessions.models import Session
25
+ from turnloop.sessions.store import SessionStore, default_project_dir
26
+ from turnloop.tools.base import ToolRegistry
27
+ from turnloop.tools.builtin import build_registry, skills_segment
28
+ from turnloop.tui.bridge import UIChannel
29
+
30
+ # One limiter per (provider name, model) for the whole process. This is what makes
31
+ # a subagent fan-out respect vLLM's --max-num-seqs rather than queueing 30
32
+ # requests at a server configured for 16.
33
+ _LIMITERS: dict[str, anyio.CapacityLimiter] = {}
34
+
35
+
36
+ def limiter_for(provider: Provider) -> anyio.CapacityLimiter:
37
+ key = f"{provider.name}:{provider.model}"
38
+ if key not in _LIMITERS:
39
+ _LIMITERS[key] = anyio.CapacityLimiter(max(1, provider.caps.max_concurrent_requests))
40
+ return _LIMITERS[key]
41
+
42
+
43
+ @dataclass
44
+ class Agent:
45
+ loop: AgentLoop
46
+ provider: Provider
47
+ session: Session
48
+ registry: ToolRegistry
49
+ permissions: PermissionEngine
50
+ store: SessionStore | None
51
+ mcp: MCPManager | None = None
52
+
53
+ async def aclose(self) -> None:
54
+ await self.loop.aclose()
55
+ if self.mcp is not None:
56
+ await self.mcp.aclose()
57
+ if self.store is not None:
58
+ self.store.close()
59
+
60
+ async def start(self) -> dict[str, bool]:
61
+ """Connect MCP servers and fire SessionStart hooks.
62
+
63
+ Separate from construction because both touch the network and the
64
+ filesystem, and construction has to stay synchronous for the TUI's
65
+ __init__. Callers that skip it simply get no MCP tools.
66
+ """
67
+ results: dict[str, bool] = {}
68
+ if self.mcp is not None:
69
+ results = await self.mcp.connect_all()
70
+ from turnloop.mcp.adapter import adapters_for
71
+
72
+ for adapter in adapters_for(self.mcp):
73
+ self.registry.add(adapter)
74
+
75
+ if self.loop.hooks is not None:
76
+ await self.loop.hooks.run_session_event("SessionStart") # type: ignore[attr-defined]
77
+ return results
78
+
79
+
80
+ def create_agent(
81
+ settings: Settings,
82
+ cwd: Path,
83
+ ui: UIChannel,
84
+ *,
85
+ persist: bool = True,
86
+ resume: str | None = None,
87
+ provider: Provider | None = None,
88
+ system_variant: str | None = None,
89
+ compaction_strategy: str | None = None,
90
+ ) -> Agent:
91
+ provider = provider or build_provider(settings.provider, settings.provider_config())
92
+ registry = build_registry(settings, cwd)
93
+ permissions = PermissionEngine.from_config(
94
+ settings.permissions, settings.permission_mode, settings.project_root, cwd
95
+ )
96
+
97
+ session: Session | None = None
98
+ store: SessionStore | None = None
99
+ if resume:
100
+ session = SessionStore.resume(settings.project_root, resume)
101
+ if session is not None:
102
+ store = session.store # type: ignore[assignment]
103
+ if session is None:
104
+ session = Session(
105
+ provider=settings.provider, model=provider.model, cwd=str(cwd)
106
+ )
107
+ if persist:
108
+ default_project_dir(settings.project_root)
109
+ store = SessionStore.attach(session, settings.project_root)
110
+
111
+ system = build_system(
112
+ settings=settings,
113
+ cwd=cwd,
114
+ registry=registry,
115
+ caps=provider.caps,
116
+ mode=settings.permission_mode,
117
+ variant=system_variant,
118
+ )
119
+ if skills := skills_segment(registry):
120
+ system.append(skills)
121
+
122
+ compactor = Compactor(
123
+ config=settings.compaction,
124
+ max_context=provider.caps.max_context,
125
+ provider=provider,
126
+ ui=ui,
127
+ strategy=compaction_strategy or "summarize", # type: ignore[arg-type]
128
+ )
129
+
130
+ hooks = None
131
+ if settings.hooks:
132
+ from turnloop.hooks.runner import HookRunner
133
+
134
+ hooks = HookRunner(settings, cwd, session.session_id)
135
+
136
+ mcp = None
137
+ if any(cfg.enabled for cfg in settings.mcp_servers.values()):
138
+ mcp = MCPManager(settings.mcp_servers)
139
+
140
+ loop = AgentLoop(
141
+ provider=provider,
142
+ registry=registry,
143
+ session=session,
144
+ permissions=permissions,
145
+ settings=settings,
146
+ ui=ui,
147
+ cwd=cwd,
148
+ compactor=compactor,
149
+ hooks=hooks,
150
+ system=system,
151
+ limiter=limiter_for(provider),
152
+ )
153
+
154
+ # Tools that need to reach the agent itself (Task spawning a child, WebFetch
155
+ # summarizing a page) get it through extras rather than through imports, so
156
+ # the dependency stays one-directional.
157
+ loop._extras.update(
158
+ {
159
+ "provider": provider,
160
+ "registry": registry,
161
+ "ui": ui,
162
+ "limiter": loop.limiter,
163
+ "settings": settings,
164
+ }
165
+ )
166
+
167
+ return Agent(
168
+ loop=loop,
169
+ provider=provider,
170
+ session=session,
171
+ registry=registry,
172
+ permissions=permissions,
173
+ store=store,
174
+ mcp=mcp,
175
+ )
@@ -0,0 +1,150 @@
1
+ """Headless execution: `turnloop -p "..."`.
2
+
3
+ Exists for three audiences, in ascending order of how much this project depends on
4
+ it: humans scripting the tool, CI, and the experiment runner. Being able to run a
5
+ full agent turn with no terminal is what makes the measurements reproducible.
6
+
7
+ `--json` emits one JSON object per event, so the output is parseable rather than
8
+ scraped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from dataclasses import asdict, is_dataclass
16
+ from pathlib import Path
17
+
18
+ from turnloop.agent.factory import create_agent
19
+ from turnloop.config import Settings
20
+ from turnloop.core.events import (
21
+ CompactionHappened,
22
+ ProviderStatus,
23
+ TextDelta,
24
+ ToolFinished,
25
+ ToolStarted,
26
+ UIEvent,
27
+ )
28
+ from turnloop.permissions.engine import PermissionDecision, PermissionRequest, Scope
29
+ from turnloop.tui.bridge import UIChannel
30
+
31
+
32
+ class PrintChannel(UIChannel):
33
+ """Streams to stdout. Declines every permission prompt, loudly.
34
+
35
+ Auto-approving in a non-interactive session would mean `turnloop -p` silently
36
+ running whatever a model asked for. Denying instead produces an error result
37
+ the model can react to, and tells the user which rule would have allowed it.
38
+ """
39
+
40
+ def __init__(self, json_mode: bool = False, quiet: bool = False,
41
+ auto_approve: bool = False):
42
+ self.json_mode = json_mode
43
+ self.quiet = quiet
44
+ self.auto_approve = auto_approve
45
+ self.declined: list[str] = []
46
+ self._in_text = False
47
+
48
+ async def send(self, event: UIEvent) -> None:
49
+ if self.json_mode:
50
+ self._emit_json(event)
51
+ return
52
+ if self.quiet:
53
+ if isinstance(event, TextDelta):
54
+ sys.stdout.write(event.text)
55
+ sys.stdout.flush()
56
+ return
57
+
58
+ if isinstance(event, TextDelta):
59
+ sys.stdout.write(event.text)
60
+ sys.stdout.flush()
61
+ self._in_text = True
62
+ return
63
+
64
+ if isinstance(event, ToolStarted):
65
+ self._break()
66
+ print(f" · {event.summary}", file=sys.stderr)
67
+ elif isinstance(event, ToolFinished):
68
+ if event.is_error:
69
+ print(f" ! {event.name} failed", file=sys.stderr)
70
+ elif isinstance(event, ProviderStatus):
71
+ self._break()
72
+ print(f" … {event.text}", file=sys.stderr)
73
+ elif isinstance(event, CompactionHappened):
74
+ self._break()
75
+ print(
76
+ f" ⤺ compacted ({event.tier}): "
77
+ f"{event.tokens_before:,} → {event.tokens_after:,} tokens",
78
+ file=sys.stderr,
79
+ )
80
+
81
+ def _break(self) -> None:
82
+ if self._in_text:
83
+ sys.stdout.write("\n")
84
+ sys.stdout.flush()
85
+ self._in_text = False
86
+
87
+ def _emit_json(self, event: UIEvent) -> None:
88
+ payload = asdict(event) if is_dataclass(event) else {"repr": repr(event)}
89
+ print(
90
+ json.dumps({"event": type(event).__name__, **payload}, default=str),
91
+ flush=True,
92
+ )
93
+
94
+ async def ask(self, request: PermissionRequest) -> PermissionDecision:
95
+ if self.auto_approve:
96
+ return PermissionDecision(approved=True, scope=Scope.ONCE)
97
+ self.declined.append(request.summary)
98
+ print(
99
+ f" ✗ needs approval, declined (non-interactive): {request.summary}\n"
100
+ f" allow it with: permissions.allow += [\"{request.suggested_rule}\"]",
101
+ file=sys.stderr,
102
+ )
103
+ return PermissionDecision(
104
+ approved=False,
105
+ reason=(
106
+ "This is a non-interactive session, so nobody can approve tool calls. "
107
+ f"Either avoid this call or tell the user to add the rule "
108
+ f"{request.suggested_rule!r} to their settings."
109
+ ),
110
+ )
111
+
112
+
113
+ async def run_headless(settings: Settings, cwd: Path, prompt: str, json_mode: bool,
114
+ resume: str | None = None, auto_approve: bool = False) -> int:
115
+ channel = PrintChannel(json_mode=json_mode, auto_approve=auto_approve)
116
+ agent = create_agent(settings, cwd, channel, resume=resume)
117
+
118
+ try:
119
+ await agent.start() # MCP connect + SessionStart hooks
120
+ result = await agent.loop.run_turn(prompt)
121
+ except Exception as exc: # noqa: BLE001 - a CLI must not traceback at the user
122
+ print(f"\nerror: {exc}", file=sys.stderr)
123
+ return 1
124
+ finally:
125
+ await agent.aclose()
126
+
127
+ if not json_mode:
128
+ print()
129
+ cost = agent.session.cost
130
+ parts = [
131
+ f"{cost.usage.input_tokens:,} in",
132
+ f"{cost.usage.output_tokens:,} out",
133
+ ]
134
+ if cost.cost_usd:
135
+ parts.append(f"${cost.cost_usd:.4f}")
136
+ if (gpu := agent.provider.gpu_seconds()) is not None:
137
+ hourly = agent.provider.caps.cost_per_hour
138
+ parts.append(f"GPU {gpu / 60:.1f} min ≈ ${gpu / 3600 * hourly:.2f}")
139
+ print(f"[{' · '.join(parts)}]", file=sys.stderr)
140
+
141
+ if agent.provider.caps.cost_per_hour and agent.provider.first_request_at:
142
+ # This endpoint bills by the hour and scales down only after ten idle
143
+ # minutes. Not saying this is how a $18/hr GPU stays up overnight.
144
+ print(
145
+ "\nThe self-hosted endpoint is still running. Stop it when you are done:\n"
146
+ " modal app stop glm-5-2-serve",
147
+ file=sys.stderr,
148
+ )
149
+
150
+ return 0 if result is not None else 1