tynex 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 (63) hide show
  1. tynex/__init__.py +3 -0
  2. tynex/__main__.py +4 -0
  3. tynex/agent/__init__.py +1 -0
  4. tynex/agent/budget.py +127 -0
  5. tynex/agent/loop.py +356 -0
  6. tynex/agent/permissions.py +202 -0
  7. tynex/agent/settings.json +10 -0
  8. tynex/agent/skills/code-review/SKILL.md +504 -0
  9. tynex/agent/types.py +126 -0
  10. tynex/ai/__init__.py +1 -0
  11. tynex/ai/model_api.py +214 -0
  12. tynex/ai/models.py +396 -0
  13. tynex/ai/providers/__init__.py +38 -0
  14. tynex/ai/providers/anthropic.py +246 -0
  15. tynex/ai/providers/base.py +377 -0
  16. tynex/ai/providers/copilot.py +510 -0
  17. tynex/ai/providers/faux.py +116 -0
  18. tynex/ai/providers/google.py +181 -0
  19. tynex/ai/providers/openai.py +182 -0
  20. tynex/ai/types.py +380 -0
  21. tynex/cli.py +829 -0
  22. tynex/coding/__init__.py +1 -0
  23. tynex/coding/agent_session.py +933 -0
  24. tynex/coding/checkpoints.py +182 -0
  25. tynex/coding/mcp.py +222 -0
  26. tynex/coding/mcp_settings.py +115 -0
  27. tynex/coding/mcp_tools.py +155 -0
  28. tynex/coding/model_catalog.py +167 -0
  29. tynex/coding/permissions.py +80 -0
  30. tynex/coding/process.py +31 -0
  31. tynex/coding/session.py +631 -0
  32. tynex/coding/session_commands.py +412 -0
  33. tynex/coding/settings.py +252 -0
  34. tynex/coding/signin.py +539 -0
  35. tynex/coding/skills.py +119 -0
  36. tynex/coding/system_prompt.py +97 -0
  37. tynex/coding/tools/__init__.py +35 -0
  38. tynex/coding/tools/edit.py +261 -0
  39. tynex/coding/tools/path_utils.py +24 -0
  40. tynex/coding/tools/read.py +151 -0
  41. tynex/coding/tools/readonly.py +311 -0
  42. tynex/coding/tools/shell.py +294 -0
  43. tynex/coding/tools/truncate.py +161 -0
  44. tynex/coding/tools/websearch.py +83 -0
  45. tynex/coding/tools/write.py +57 -0
  46. tynex/coding/websearch/__init__.py +9 -0
  47. tynex/coding/websearch/providers.py +233 -0
  48. tynex/coding/websearch/readable.py +136 -0
  49. tynex/http.py +128 -0
  50. tynex/sdk/__init__.py +49 -0
  51. tynex/sdk/agent.py +336 -0
  52. tynex/sdk/tools.py +264 -0
  53. tynex/tui/__init__.py +1 -0
  54. tynex/tui/app.py +583 -0
  55. tynex/tui/prompt.py +246 -0
  56. tynex/tui/renderer.py +210 -0
  57. tynex/tui/select.py +59 -0
  58. tynex-0.1.0.dist-info/METADATA +466 -0
  59. tynex-0.1.0.dist-info/RECORD +63 -0
  60. tynex-0.1.0.dist-info/WHEEL +4 -0
  61. tynex-0.1.0.dist-info/entry_points.txt +2 -0
  62. tynex-0.1.0.dist-info/licenses/LICENSE +201 -0
  63. tynex-0.1.0.dist-info/licenses/NOTICE +4 -0
tynex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Tynex — a minimal terminal coding agent harness."""
2
+
3
+ __version__ = "0.1.0"
tynex/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from tynex.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Agent core: loop, types, and tool contracts."""
tynex/agent/budget.py ADDED
@@ -0,0 +1,127 @@
1
+ """Budgets: optional hard caps on total tokens or cost for one run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class BudgetLimits:
12
+ """Optional caps for one run. ``None`` means unlimited."""
13
+
14
+ max_tokens: int | None = None # input + output + cache-read tokens
15
+ max_cost_usd: float | None = None
16
+
17
+ @property
18
+ def empty(self) -> bool:
19
+ return self.max_tokens is None and self.max_cost_usd is None
20
+
21
+ def to_dict(self) -> dict[str, Any]:
22
+ out: dict[str, Any] = {}
23
+ if self.max_tokens is not None:
24
+ out["maxTokens"] = self.max_tokens
25
+ if self.max_cost_usd is not None:
26
+ out["maxCostUsd"] = self.max_cost_usd
27
+ return out
28
+
29
+ @staticmethod
30
+ def from_dict(data: dict[str, Any] | None) -> BudgetLimits | None:
31
+ """Read the serialized form; unknown/legacy fields are ignored."""
32
+ if not isinstance(data, dict):
33
+ return None
34
+ kwargs: dict[str, Any] = {}
35
+ for key, field_name, cast in (("maxTokens", "max_tokens", int), ("maxCostUsd", "max_cost_usd", float)):
36
+ value = data.get(key)
37
+ if value is None or isinstance(value, bool):
38
+ continue
39
+ try:
40
+ kwargs[field_name] = cast(value)
41
+ except (TypeError, ValueError):
42
+ continue
43
+ limits = BudgetLimits(**kwargs)
44
+ return None if limits.empty else limits
45
+
46
+
47
+ def parse_budget(spec: str) -> BudgetLimits:
48
+ """Parse ``--budget``: ``cost=2.50``, ``tokens=250000``, or both comma-separated.
49
+
50
+ Raises ``ValueError`` with a user-facing message for anything else.
51
+ """
52
+ limits = BudgetLimits()
53
+ for raw_item in spec.split(","):
54
+ item = raw_item.strip()
55
+ if not item:
56
+ continue
57
+ key, separator, raw_value = item.partition("=")
58
+ if not separator:
59
+ raise ValueError(f'expected cost=AMOUNT or tokens=COUNT, got "{item}"')
60
+ key = key.strip().lower()
61
+ value_text = raw_value.strip().lstrip("$")
62
+ if not value_text:
63
+ raise ValueError(f'missing value for "{key}"')
64
+ try:
65
+ value = float(value_text)
66
+ except ValueError as exc:
67
+ raise ValueError(f'invalid {key} value "{raw_value.strip()}"') from exc
68
+ if key == "cost":
69
+ if value <= 0:
70
+ raise ValueError("cost budget must be greater than zero")
71
+ limits.max_cost_usd = value
72
+ elif key == "tokens":
73
+ if value <= 0 or not value.is_integer():
74
+ raise ValueError("tokens budget must be a positive whole number")
75
+ limits.max_tokens = int(value)
76
+ else:
77
+ raise ValueError(f'unknown budget field "{key}" (use cost or tokens)')
78
+ if limits.empty:
79
+ raise ValueError("empty budget — use cost=AMOUNT or tokens=COUNT")
80
+ return limits
81
+
82
+
83
+ @dataclass
84
+ class BudgetState:
85
+ """Mutable counters for one session."""
86
+
87
+ steps: int = 0
88
+ tool_calls: int = 0
89
+ tokens: int = 0
90
+ cost_usd: float = 0.0
91
+ started: float | None = None
92
+
93
+ def reset(self) -> None:
94
+ self.steps = 0
95
+ self.tool_calls = 0
96
+ self.tokens = 0
97
+ self.cost_usd = 0.0
98
+ self.started = None
99
+
100
+ def add_usage(self, usage: Any) -> None:
101
+ self.tokens += int(getattr(usage, "total_tokens", 0) or 0) + int(getattr(usage, "cache_read", 0) or 0)
102
+ self.cost_usd += float(getattr(usage, "cost", 0.0) or 0.0)
103
+
104
+ def exceeded(self, limits: BudgetLimits | None) -> str | None:
105
+ """First exceeded limit as a human-readable reason, or ``None``."""
106
+ if limits is None:
107
+ return None
108
+ if limits.max_tokens is not None and self.tokens >= limits.max_tokens:
109
+ return f"token limit reached ({self.tokens:,}/{limits.max_tokens:,} tokens)"
110
+ if limits.max_cost_usd is not None and self.cost_usd >= limits.max_cost_usd:
111
+ return f"cost limit reached (${self.cost_usd:.4f}/${limits.max_cost_usd:.4f})"
112
+ return None
113
+
114
+ def begin(self) -> None:
115
+ if self.started is None:
116
+ self.started = time.monotonic()
117
+
118
+ def snapshot(self, limits: BudgetLimits | None = None) -> dict[str, Any]:
119
+ elapsed = (time.monotonic() - self.started) if self.started is not None else 0.0
120
+ return {
121
+ "steps": self.steps,
122
+ "toolCalls": self.tool_calls,
123
+ "tokens": self.tokens,
124
+ "cost": round(self.cost_usd, 6),
125
+ "seconds": round(elapsed, 1),
126
+ "limits": limits.to_dict() if limits else {},
127
+ }
tynex/agent/loop.py ADDED
@@ -0,0 +1,356 @@
1
+ """The agent loop: streaming, tool execution, and turn coordination."""
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from tynex.agent.types import AgentContext, AgentLoopConfig, EventSink, PrepareNextTurnContext, ToolResult
7
+ from tynex.ai.providers.base import LlmContext, StreamOptions
8
+ from tynex.ai.types import AbortSignal, AssistantMessage, Message, TextContent, ToolCall, ToolResultMessage
9
+
10
+
11
+ def _default_sink(event: dict[str, Any]) -> None:
12
+ """Discard events when the caller did not supply a sink."""
13
+
14
+
15
+ def _emit_ready(message: Message, emit: EventSink) -> None:
16
+ emit({"type": "message_start", "message": message})
17
+ emit({"type": "message_end", "message": message})
18
+
19
+
20
+ def run_agent_loop(
21
+ prompts: list[Message],
22
+ context: AgentContext,
23
+ config: AgentLoopConfig,
24
+ signal: AbortSignal | None = None,
25
+ emit: EventSink | None = None,
26
+ ) -> list[Message]:
27
+ """Start a turn with one or more new messages (usually a single user message)."""
28
+ sink = emit or _default_sink
29
+ new_messages: list[Message] = list(prompts)
30
+ current = AgentContext(messages=[*context.messages, *prompts], tools=context.tools)
31
+ sink({"type": "agent_start"})
32
+ sink({"type": "turn_start"})
33
+ for message in prompts:
34
+ _emit_ready(message, sink)
35
+ final_context = _run_loop(current, new_messages, config, signal, sink)
36
+ context.messages = final_context.messages
37
+ context.tools = final_context.tools
38
+ return new_messages
39
+
40
+
41
+ def _run_loop(
42
+ initial_context: AgentContext,
43
+ new_messages: list[Message],
44
+ initial_config: AgentLoopConfig,
45
+ signal: AbortSignal | None,
46
+ sink: EventSink,
47
+ ) -> AgentContext:
48
+ current = initial_context
49
+ config = initial_config
50
+ last_completed_turn: PrepareNextTurnContext | None = None
51
+ pending: list[Message] = list(config.get_steering_messages())
52
+ while True:
53
+ has_more_tool_calls = True
54
+ while has_more_tool_calls or pending:
55
+ if config.before_model_call is not None:
56
+ stop_reason = config.before_model_call()
57
+ if stop_reason:
58
+ sink({"type": "limit_reached", "reason": stop_reason})
59
+ sink({"type": "agent_end", "messages": new_messages})
60
+ return current
61
+ prepared: list[Message] = []
62
+ if last_completed_turn is not None:
63
+ result = config.prepare_next_turn(last_completed_turn) if config.prepare_next_turn else None
64
+ if result:
65
+ if result.context is not None:
66
+ current = result.context
67
+ prepared = list(result.messages or [])
68
+ if result.model is not None:
69
+ config.model = result.model
70
+ if result.thinking_level is not None:
71
+ config.thinking = result.thinking_level
72
+ if not pending:
73
+ pending = list(config.get_steering_messages())
74
+ sink({"type": "turn_start"})
75
+ for message in [*prepared, *pending]:
76
+ _emit_ready(message, sink)
77
+ current.messages.append(message)
78
+ new_messages.append(message)
79
+ pending = []
80
+ message = _stream_assistant_response(current, config, signal, sink)
81
+ new_messages.append(message)
82
+ if message.stop_reason in ("error", "aborted"):
83
+ last_completed_turn = PrepareNextTurnContext(message, [], current, new_messages)
84
+ sink({"type": "turn_end", "message": message, "toolResults": []})
85
+ sink({"type": "agent_end", "messages": new_messages})
86
+ return current
87
+ tool_calls = message.tool_calls()
88
+ tool_results: list[ToolResultMessage] = []
89
+ has_more_tool_calls = False
90
+ if tool_calls:
91
+ if message.stop_reason == "length":
92
+ batch = _fail_truncated_tool_calls(tool_calls, sink)
93
+ else:
94
+ batch = _execute_tool_calls(current, message, config, signal, sink)
95
+ tool_results = batch["messages"]
96
+ has_more_tool_calls = not batch["terminate"]
97
+ for result_message in tool_results:
98
+ current.messages.append(result_message)
99
+ new_messages.append(result_message)
100
+ last_completed_turn = PrepareNextTurnContext(message, list(tool_results), current, new_messages)
101
+ sink({"type": "turn_end", "message": message, "toolResults": tool_results})
102
+ pending = list(config.get_steering_messages())
103
+ follow_ups = list(config.get_follow_up_messages())
104
+ if follow_ups:
105
+ pending = follow_ups
106
+ continue
107
+ break
108
+ sink({"type": "agent_end", "messages": new_messages})
109
+ return current
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Assistant response streaming
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def _stream_assistant_response(
118
+ current: AgentContext, config: AgentLoopConfig, signal: AbortSignal | None, sink: EventSink
119
+ ) -> AssistantMessage:
120
+ llm_messages = config.convert_to_llm(current.messages)
121
+ llm_context = LlmContext(messages=llm_messages, tools=[tool.declaration() for tool in config.tools])
122
+ options = StreamOptions(
123
+ api_key=config.api_key,
124
+ thinking=config.thinking,
125
+ temperature=config.temperature,
126
+ max_tokens=config.max_tokens,
127
+ signal=signal,
128
+ stealth=config.stealth,
129
+ )
130
+ partial: AssistantMessage | None = None
131
+ added_partial = False
132
+ final: AssistantMessage | None = None
133
+ for event in config.stream_fn(config.model, llm_context, options):
134
+ etype = event.get("type")
135
+ if etype == "start":
136
+ partial = event["partial"]
137
+ current.messages.append(partial)
138
+ added_partial = True
139
+ sink({"type": "message_start", "message": partial})
140
+ elif etype in (
141
+ "text_start",
142
+ "text_delta",
143
+ "text_end",
144
+ "thinking_start",
145
+ "thinking_delta",
146
+ "thinking_end",
147
+ "toolcall_start",
148
+ "toolcall_delta",
149
+ "toolcall_end",
150
+ ):
151
+ partial = event["partial"]
152
+ if added_partial:
153
+ current.messages[-1] = partial
154
+ sink({"type": "message_update", "message": partial, "delta": event})
155
+ elif etype in ("done", "error"):
156
+ final = event["partial"]
157
+ if added_partial:
158
+ current.messages[-1] = final
159
+ else:
160
+ current.messages.append(final)
161
+ sink({"type": "message_start", "message": final})
162
+ sink({"type": "message_end", "message": final})
163
+ return final
164
+ final = partial or AssistantMessage(
165
+ content=[TextContent(text="")],
166
+ api=config.model.api,
167
+ provider=config.model.provider,
168
+ model=config.model.id,
169
+ stop_reason="error",
170
+ error_message="Provider produced no response",
171
+ )
172
+ if added_partial:
173
+ current.messages[-1] = final
174
+ else:
175
+ current.messages.append(final)
176
+ sink({"type": "message_end", "message": final})
177
+ return final
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Tool execution
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def _fail_truncated_tool_calls(tool_calls: list[ToolCall], sink: EventSink) -> dict[str, Any]:
186
+ messages: list[ToolResultMessage] = []
187
+ for call in tool_calls:
188
+ sink({"type": "tool_execution_start", "toolCallId": call.id, "toolName": call.name, "args": call.arguments})
189
+ result = ToolResult.text(
190
+ f'Tool call "{call.name}" was not executed: the response hit the output token limit, '
191
+ "so its arguments may be truncated. Re-issue the tool call with complete arguments.",
192
+ is_error=True,
193
+ )
194
+ finalized = {"toolCall": call, "result": result, "isError": True, "durationMs": 0}
195
+ sink(
196
+ {
197
+ "type": "tool_execution_end",
198
+ "toolCallId": call.id,
199
+ "toolName": call.name,
200
+ "result": result,
201
+ "isError": True,
202
+ "durationMs": 0,
203
+ }
204
+ )
205
+ message = _tool_result_message(finalized)
206
+ _emit_ready(message, sink)
207
+ messages.append(message)
208
+ return {"messages": messages, "terminate": False}
209
+
210
+
211
+ def _execute_tool_calls(
212
+ current: AgentContext,
213
+ assistant_message: AssistantMessage,
214
+ config: AgentLoopConfig,
215
+ signal: AbortSignal | None,
216
+ sink: EventSink,
217
+ ) -> dict[str, Any]:
218
+ tool_calls = assistant_message.tool_calls()
219
+ finalized_calls: list[dict[str, Any]] = []
220
+ messages: list[ToolResultMessage] = []
221
+ for call in tool_calls:
222
+ sink({"type": "tool_execution_start", "toolCallId": call.id, "toolName": call.name, "args": call.arguments})
223
+ prepared = _prepare_tool_call(current, assistant_message, call, config, signal)
224
+ if prepared["kind"] == "immediate":
225
+ finalized = {
226
+ "toolCall": call,
227
+ "result": prepared["result"],
228
+ "isError": prepared["isError"],
229
+ "durationMs": 0,
230
+ }
231
+ else:
232
+ finalized = _execute_prepared_tool_call(prepared, signal, _update_sink(call, sink))
233
+ sink(
234
+ {
235
+ "type": "tool_execution_end",
236
+ "toolCallId": call.id,
237
+ "toolName": call.name,
238
+ "result": finalized["result"],
239
+ "isError": finalized["isError"],
240
+ "durationMs": finalized.get("durationMs"),
241
+ }
242
+ )
243
+ message = _tool_result_message(finalized)
244
+ _emit_ready(message, sink)
245
+ finalized_calls.append(finalized)
246
+ messages.append(message)
247
+ if signal is not None and signal.aborted:
248
+ break
249
+ # A batch interrupted mid-way must still answer every tool call: providers
250
+ # reject a replayed tool_use block without a matching tool_result.
251
+ for call in tool_calls[len(messages) :]:
252
+ sink({"type": "tool_execution_start", "toolCallId": call.id, "toolName": call.name, "args": call.arguments})
253
+ result = ToolResult.text("Operation aborted", is_error=True)
254
+ finalized = {"toolCall": call, "result": result, "isError": True, "durationMs": 0}
255
+ sink(
256
+ {
257
+ "type": "tool_execution_end",
258
+ "toolCallId": call.id,
259
+ "toolName": call.name,
260
+ "result": result,
261
+ "isError": True,
262
+ "durationMs": 0,
263
+ }
264
+ )
265
+ message = _tool_result_message(finalized)
266
+ _emit_ready(message, sink)
267
+ finalized_calls.append(finalized)
268
+ messages.append(message)
269
+ terminate = bool(finalized_calls) and all(f["result"].terminate for f in finalized_calls)
270
+ return {"messages": messages, "terminate": terminate}
271
+
272
+
273
+ def _prepare_tool_call(
274
+ current: AgentContext,
275
+ assistant_message: AssistantMessage,
276
+ call: ToolCall,
277
+ config: AgentLoopConfig,
278
+ signal: AbortSignal | None,
279
+ ) -> dict[str, Any]:
280
+ tool = next((t for t in config.tools if t.name == call.name), None)
281
+ if tool is None:
282
+ return {
283
+ "kind": "immediate",
284
+ "result": ToolResult.text(f"Tool {call.name} not found", is_error=True),
285
+ "isError": True,
286
+ }
287
+ try:
288
+ if tool.prepare_arguments:
289
+ prepared_args = tool.prepare_arguments(call.arguments)
290
+ if prepared_args is not call.arguments:
291
+ call = ToolCall(id=call.id, name=call.name, arguments=prepared_args)
292
+ if config.before_tool_call:
293
+ outcome = config.before_tool_call(
294
+ {
295
+ "assistantMessage": assistant_message,
296
+ "toolCall": call,
297
+ "tool": tool,
298
+ "args": call.arguments,
299
+ "context": current,
300
+ },
301
+ signal,
302
+ )
303
+ if outcome and outcome.get("block"):
304
+ result = ToolResult.text(outcome.get("reason") or "Tool execution was blocked", is_error=True)
305
+ if outcome.get("terminate"):
306
+ result.terminate = True
307
+ return {"kind": "immediate", "result": result, "isError": True}
308
+ if signal is not None and signal.aborted:
309
+ return {"kind": "immediate", "result": ToolResult.text("Operation aborted", is_error=True), "isError": True}
310
+ return {"kind": "prepared", "toolCall": call, "tool": tool, "args": call.arguments, "ctx": config.tool_context}
311
+ except Exception as exc:
312
+ return {"kind": "immediate", "result": ToolResult.text(str(exc), is_error=True), "isError": True}
313
+
314
+
315
+ def _execute_prepared_tool_call(prepared: dict[str, Any], signal: AbortSignal | None, on_update) -> dict[str, Any]:
316
+ started = time.monotonic()
317
+ try:
318
+ result = prepared["tool"].execute(
319
+ prepared["toolCall"].id, prepared["args"], signal, on_update, prepared.get("ctx")
320
+ )
321
+ is_error = result.is_error
322
+ except Exception as exc:
323
+ result, is_error = ToolResult.text(str(exc), is_error=True), True
324
+ return {
325
+ "toolCall": prepared["toolCall"],
326
+ "result": result,
327
+ "isError": is_error,
328
+ "durationMs": int((time.monotonic() - started) * 1000),
329
+ }
330
+
331
+
332
+ def _update_sink(call: ToolCall, sink: EventSink):
333
+ def on_update(partial: ToolResult) -> None:
334
+ sink(
335
+ {
336
+ "type": "tool_execution_update",
337
+ "toolCallId": call.id,
338
+ "toolName": call.name,
339
+ "args": call.arguments,
340
+ "partialResult": partial,
341
+ }
342
+ )
343
+
344
+ return on_update
345
+
346
+
347
+ def _tool_result_message(finalized: dict[str, Any]) -> ToolResultMessage:
348
+ result: ToolResult = finalized["result"]
349
+ return ToolResultMessage(
350
+ tool_call_id=finalized["toolCall"].id,
351
+ tool_name=finalized["toolCall"].name,
352
+ content=list(result.content or []),
353
+ is_error=bool(finalized["isError"]),
354
+ details=result.details,
355
+ duration_ms=finalized.get("durationMs"),
356
+ )