indent 0.1.16__tar.gz → 0.1.18__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.
Files changed (52) hide show
  1. {indent-0.1.16 → indent-0.1.18}/.gitignore +14 -0
  2. {indent-0.1.16 → indent-0.1.18}/PKG-INFO +1 -1
  3. {indent-0.1.16 → indent-0.1.18}/exponent/__init__.py +2 -2
  4. {indent-0.1.16 → indent-0.1.18}/exponent/cli.py +0 -2
  5. {indent-0.1.16 → indent-0.1.18}/exponent/commands/common.py +12 -30
  6. {indent-0.1.16 → indent-0.1.18}/exponent/commands/run_commands.py +1 -2
  7. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/cli_rpc_types.py +23 -0
  8. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/client.py +67 -11
  9. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/languages/shell_streaming.py +4 -1
  10. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/tool_execution.py +79 -15
  11. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/truncation.py +10 -0
  12. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/types.py +19 -18
  13. {indent-0.1.16 → indent-0.1.18}/exponent/utils/version.py +1 -1
  14. {indent-0.1.16 → indent-0.1.18}/pyproject.toml +1 -1
  15. indent-0.1.16/exponent/commands/workflow_commands.py +0 -111
  16. {indent-0.1.16 → indent-0.1.18}/exponent/commands/cloud_commands.py +0 -0
  17. {indent-0.1.16 → indent-0.1.18}/exponent/commands/config_commands.py +0 -0
  18. {indent-0.1.16 → indent-0.1.18}/exponent/commands/settings.py +0 -0
  19. {indent-0.1.16 → indent-0.1.18}/exponent/commands/types.py +0 -0
  20. {indent-0.1.16 → indent-0.1.18}/exponent/commands/upgrade.py +0 -0
  21. {indent-0.1.16 → indent-0.1.18}/exponent/commands/utils.py +0 -0
  22. {indent-0.1.16 → indent-0.1.18}/exponent/core/config.py +0 -0
  23. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/__init__.py +0 -0
  24. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/client.py +0 -0
  25. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/get_chats_query.py +0 -0
  26. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/github_config_queries.py +0 -0
  27. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/mutations.py +0 -0
  28. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/queries.py +0 -0
  29. {indent-0.1.16 → indent-0.1.18}/exponent/core/graphql/subscriptions.py +0 -0
  30. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/checkpoints.py +0 -0
  31. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/code_execution.py +0 -0
  32. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/error_info.py +0 -0
  33. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/exceptions.py +0 -0
  34. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/file_write.py +0 -0
  35. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/files.py +0 -0
  36. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/git.py +0 -0
  37. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/http_fetch.py +0 -0
  38. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/languages/python_execution.py +0 -0
  39. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/languages/types.py +0 -0
  40. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/session.py +0 -0
  41. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/system_context.py +0 -0
  42. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/tool_type_utils.py +0 -0
  43. {indent-0.1.16 → indent-0.1.18}/exponent/core/remote_execution/utils.py +0 -0
  44. {indent-0.1.16 → indent-0.1.18}/exponent/core/types/__init__.py +0 -0
  45. {indent-0.1.16 → indent-0.1.18}/exponent/core/types/command_data.py +0 -0
  46. {indent-0.1.16 → indent-0.1.18}/exponent/core/types/event_types.py +0 -0
  47. {indent-0.1.16 → indent-0.1.18}/exponent/core/types/generated/__init__.py +0 -0
  48. {indent-0.1.16 → indent-0.1.18}/exponent/core/types/generated/strategy_info.py +0 -0
  49. {indent-0.1.16 → indent-0.1.18}/exponent/migration-docs/login.md +0 -0
  50. {indent-0.1.16 → indent-0.1.18}/exponent/py.typed +0 -0
  51. {indent-0.1.16 → indent-0.1.18}/exponent/utils/__init__.py +0 -0
  52. {indent-0.1.16 → indent-0.1.18}/exponent/utils/colors.py +0 -0
@@ -14,10 +14,14 @@ workflow_input.json
14
14
  .anthropic.log.json
15
15
  CLAUDE.local.md
16
16
  indent.local.md
17
+ plan.md
17
18
 
18
19
  # C extensions
19
20
  *.so
20
21
 
22
+ # logs
23
+ logs/
24
+
21
25
  # Distribution / packaging
22
26
  .Python
23
27
  build/
@@ -64,6 +68,11 @@ coverage.xml
64
68
  .hypothesis/
65
69
  .pytest_cache/
66
70
  log.txt
71
+ *regression_results*.json
72
+
73
+ # Profiling
74
+ *profile.json
75
+ *.callgrind
67
76
 
68
77
  # Translations
69
78
  *.mo
@@ -179,3 +188,8 @@ private-key.pem
179
188
 
180
189
  # Generated version files
181
190
  python_modules/exponent/exponent/__init__.py
191
+
192
+ # LLM plan variants
193
+ plans/*
194
+ plan.md
195
+ .PLAN.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: indent
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Indent is an AI Pair Programmer
5
5
  Author-email: Sashank Thupukari <sashank@exponent.run>
6
6
  Requires-Python: <3.13,>=3.10
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.16'
32
- __version_tuple__ = version_tuple = (0, 1, 16)
31
+ __version__ = version = '0.1.18'
32
+ __version_tuple__ = version_tuple = (0, 1, 18)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -7,7 +7,6 @@ from exponent.commands.config_commands import config_cli
7
7
  from exponent.commands.run_commands import run, run_cli
8
8
  from exponent.commands.types import ExponentGroup, exponent_cli_group
9
9
  from exponent.commands.upgrade import upgrade_cli
10
- from exponent.commands.workflow_commands import workflow_cli
11
10
  from exponent.utils.version import (
12
11
  get_installed_version,
13
12
  )
@@ -34,7 +33,6 @@ sources: list[ExponentGroup] = [
34
33
  run_cli, # Run AI chat commands
35
34
  upgrade_cli, # Upgrade-related commands
36
35
  cloud_cli, # Cloud commands
37
- workflow_cli, # Workflow commands
38
36
  ]
39
37
 
40
38
  for source in sources:
@@ -37,7 +37,8 @@ from exponent.core.remote_execution.exceptions import (
37
37
  )
38
38
  from exponent.core.remote_execution.files import FileCache
39
39
  from exponent.core.remote_execution.git import get_git_info
40
- from exponent.core.remote_execution.types import ChatSource, GitInfo
40
+ from exponent.core.remote_execution.session import send_exception_log
41
+ from exponent.core.remote_execution.types import ChatSource
41
42
 
42
43
  load_dotenv()
43
44
 
@@ -212,6 +213,13 @@ def run_until_complete(coro: Coroutine[Any, Any, Any]) -> Any:
212
213
  except asyncio.CancelledError:
213
214
  pass
214
215
  except ExponentError as e:
216
+ try:
217
+ settings = get_settings()
218
+ loop.run_until_complete(
219
+ send_exception_log(e, session=None, settings=settings)
220
+ )
221
+ except Exception:
222
+ pass
215
223
  click.secho(f"Encountered error: {e}", fg="red")
216
224
  click.secho(
217
225
  "The Indent team has been notified, "
@@ -321,7 +329,9 @@ async def start_client(
321
329
  if prompt:
322
330
  # If given a prompt, we also need to send a request
323
331
  # to kick off the initial turn loop for the chat
324
- raise NotImplementedError("Kicking off with initial prompt not implemented")
332
+ aux_coros.append(
333
+ start_chat_turn(api_key, base_api_url, base_ws_url, chat_uuid, prompt)
334
+ )
325
335
  elif workflow_id:
326
336
  # Similarly, if given a workflow ID, we need to send
327
337
  # a request to kick off the workflow
@@ -346,34 +356,6 @@ async def create_chat(
346
356
  return None
347
357
 
348
358
 
349
- async def get_gh_app_installation_token(
350
- api_key: str, base_api_url: str, base_ws_url: str, git_info: GitInfo
351
- ) -> dict[str, Any] | None:
352
- try:
353
- async with RemoteExecutionClient.session(
354
- api_key, base_api_url, base_ws_url, os.getcwd()
355
- ) as client:
356
- return await client.get_gh_installation_token(git_info)
357
- except (httpx.ConnectError, ExponentError) as e:
358
- click.secho(f"Error: {e}", fg="red")
359
- return None
360
-
361
-
362
- async def verify_gh_app_installation(
363
- api_key: str, base_api_url: str, base_ws_url: str, git_info: GitInfo
364
- ) -> bool:
365
- try:
366
- async with RemoteExecutionClient.session(
367
- api_key, base_api_url, base_ws_url, os.getcwd()
368
- ) as client:
369
- res = await client.get_gh_installation_token(git_info)
370
- if "token" in res:
371
- return True
372
- except (httpx.ConnectError, ExponentError) as e:
373
- click.secho(f"Error: {e}", fg="red")
374
- return False
375
-
376
-
377
359
  async def set_login_complete(api_key: str, base_api_url: str, base_ws_url: str) -> None:
378
360
  graphql_client = GraphQLClient(
379
361
  api_key=api_key, base_api_url=base_api_url, base_ws_url=base_ws_url
@@ -105,8 +105,7 @@ def run(
105
105
  sys.exit(1)
106
106
 
107
107
  if (
108
- not prompt
109
- and (not inside_ssh_session())
108
+ (not inside_ssh_session())
110
109
  and (not workflow_id)
111
110
  # If the user specified a chat ID, they probably don't want to re-launch the chat
112
111
  and (not chat_id)
@@ -51,6 +51,7 @@ class ErrorToolResult(ToolResult, tag="error"):
51
51
 
52
52
 
53
53
  READ_TOOL_NAME = "read"
54
+ READ_TOOL_ARTIFACT_NAME = "read_tool_artifact"
54
55
 
55
56
 
56
57
  class ReadToolInput(ToolInput, tag=READ_TOOL_NAME):
@@ -80,6 +81,15 @@ class ReadToolResult(ToolResult, tag=READ_TOOL_NAME):
80
81
  return "\n".join(lines)
81
82
 
82
83
 
84
+ class ReadToolArtifactResult(ToolResult, tag=READ_TOOL_ARTIFACT_NAME):
85
+ s3_uri: str
86
+ file_path: str
87
+ media_type: str
88
+
89
+ def to_text(self) -> str:
90
+ return f"[Image artifact uploaded to {self.s3_uri}]"
91
+
92
+
83
93
  LIST_TOOL_NAME = "ls"
84
94
 
85
95
 
@@ -217,6 +227,7 @@ PartialToolResultType = PartialBashToolResult
217
227
 
218
228
  ToolResultType = (
219
229
  ReadToolResult
230
+ | ReadToolArtifactResult
220
231
  | WriteToolResult
221
232
  | ListToolResult
222
233
  | GlobToolResult
@@ -273,6 +284,16 @@ class KeepAliveCliChatResponse(msgspec.Struct, tag="keep_alive_cli_chat"):
273
284
  pass
274
285
 
275
286
 
287
+ class GenerateUploadUrlRequest(msgspec.Struct, tag="generate_upload_url"):
288
+ s3_key: str
289
+ content_type: str
290
+
291
+
292
+ class GenerateUploadUrlResponse(msgspec.Struct, tag="generate_upload_url"):
293
+ upload_url: str
294
+ s3_uri: str
295
+
296
+
276
297
  class CliRpcRequest(msgspec.Struct):
277
298
  request_id: str
278
299
  request: (
@@ -283,6 +304,7 @@ class CliRpcRequest(msgspec.Struct):
283
304
  | BatchToolExecutionRequest
284
305
  | SwitchCLIChatRequest
285
306
  | KeepAliveCliChatRequest
307
+ | GenerateUploadUrlRequest
286
308
  )
287
309
 
288
310
 
@@ -305,4 +327,5 @@ class CliRpcResponse(msgspec.Struct):
305
327
  | HttpResponse
306
328
  | SwitchCLIChatResponse
307
329
  | KeepAliveCliChatResponse
330
+ | GenerateUploadUrlResponse
308
331
  )
@@ -31,6 +31,8 @@ from exponent.core.remote_execution.cli_rpc_types import (
31
31
  CliRpcResponse,
32
32
  ErrorResponse,
33
33
  ErrorToolResult,
34
+ GenerateUploadUrlRequest,
35
+ GenerateUploadUrlResponse,
34
36
  GetAllFilesRequest,
35
37
  GetAllFilesResponse,
36
38
  HttpRequest,
@@ -52,6 +54,7 @@ from exponent.core.remote_execution.http_fetch import fetch_http_content
52
54
  from exponent.core.remote_execution.session import (
53
55
  RemoteExecutionClientSession,
54
56
  get_session,
57
+ send_exception_log,
55
58
  )
56
59
  from exponent.core.remote_execution.tool_execution import (
57
60
  execute_bash_tool,
@@ -62,7 +65,6 @@ from exponent.core.remote_execution.types import (
62
65
  ChatSource,
63
66
  CLIConnectedState,
64
67
  CreateChatResponse,
65
- GitInfo,
66
68
  HeartbeatInfo,
67
69
  RemoteExecutionResponseType,
68
70
  RunWorkflowRequest,
@@ -115,6 +117,13 @@ class RemoteExecutionClient:
115
117
  # Track last request time for timeout functionality
116
118
  self._last_request_time: float | None = None
117
119
 
120
+ # Track pending upload URL requests
121
+ self._pending_upload_requests: dict[
122
+ str, asyncio.Future[GenerateUploadUrlResponse]
123
+ ] = {}
124
+ self._upload_request_lock = asyncio.Lock()
125
+ self._websocket: ClientConnection | None = None
126
+
118
127
  @property
119
128
  def working_directory(self) -> str:
120
129
  return self.current_session.working_directory
@@ -186,7 +195,21 @@ class RemoteExecutionClient:
186
195
  self._last_request_time = time.time()
187
196
 
188
197
  msg_data = json.loads(msg)
189
- if msg_data["type"] != "request":
198
+ if msg_data["type"] == "result":
199
+ data = json.dumps(msg_data["data"])
200
+ try:
201
+ response = msgspec.json.decode(data, type=CliRpcResponse)
202
+ if isinstance(response.response, GenerateUploadUrlResponse):
203
+ async with self._upload_request_lock:
204
+ if response.request_id in self._pending_upload_requests:
205
+ future = self._pending_upload_requests.pop(
206
+ response.request_id
207
+ )
208
+ future.set_result(response.response)
209
+ except Exception as e:
210
+ logger.error(f"Error handling upload URL response: {e}")
211
+ return None
212
+ elif msg_data["type"] != "request":
190
213
  return None
191
214
 
192
215
  data = json.dumps(msg_data["data"])
@@ -357,6 +380,10 @@ class RemoteExecutionClient:
357
380
  await results.put(response)
358
381
  except Exception as e:
359
382
  logger.info(f"Error handling request {request}:\n\n{e}")
383
+ try:
384
+ await send_exception_log(e, session=self.current_session)
385
+ except Exception:
386
+ pass
360
387
  async with results_lock:
361
388
  await results.put(
362
389
  CliRpcResponse(
@@ -441,6 +468,8 @@ class RemoteExecutionClient:
441
468
  if connection_tracker is not None:
442
469
  await connection_tracker.set_connected(True)
443
470
 
471
+ self._websocket = websocket
472
+
444
473
  beats: asyncio.Queue[HeartbeatInfo] = asyncio.Queue()
445
474
  requests: asyncio.Queue[CliRpcRequest] = asyncio.Queue()
446
475
  results: asyncio.Queue[CliRpcResponse] = asyncio.Queue()
@@ -523,13 +552,6 @@ class RemoteExecutionClient:
523
552
  )
524
553
  return await deserialize_api_response(response, CreateChatResponse)
525
554
 
526
- async def get_gh_installation_token(self, git_info: GitInfo) -> dict[str, Any]:
527
- response = await self.api_client.post(
528
- "/github_app/exchange_token",
529
- json=git_info.model_dump(),
530
- )
531
- return cast(dict[str, Any], response.json())
532
-
533
555
  # deprecated
534
556
  async def run_workflow(self, chat_uuid: str, workflow_id: str) -> dict[str, Any]:
535
557
  response = await self.api_client.post(
@@ -582,6 +604,38 @@ class RemoteExecutionClient:
582
604
  logger.info(f"Heartbeat response: {connected_state}")
583
605
  return connected_state
584
606
 
607
+ async def request_upload_url(
608
+ self, s3_key: str, content_type: str
609
+ ) -> GenerateUploadUrlResponse:
610
+ if self._websocket is None:
611
+ raise RuntimeError("No active websocket connection")
612
+
613
+ request_id = str(uuid.uuid4())
614
+ request = CliRpcRequest(
615
+ request_id=request_id,
616
+ request=GenerateUploadUrlRequest(s3_key=s3_key, content_type=content_type),
617
+ )
618
+
619
+ future: asyncio.Future[GenerateUploadUrlResponse] = asyncio.Future()
620
+ async with self._upload_request_lock:
621
+ self._pending_upload_requests[request_id] = future
622
+
623
+ try:
624
+ await self._websocket.send(
625
+ json.dumps({"type": "request", "data": msgspec.to_builtins(request)})
626
+ )
627
+
628
+ response = await asyncio.wait_for(future, timeout=30)
629
+ return response
630
+ except TimeoutError:
631
+ async with self._upload_request_lock:
632
+ self._pending_upload_requests.pop(request_id, None)
633
+ raise RuntimeError("Timeout waiting for upload URL response")
634
+ except Exception as e:
635
+ async with self._upload_request_lock:
636
+ self._pending_upload_requests.pop(request_id, None)
637
+ raise e
638
+
585
639
  async def handle_request(self, request: CliRpcRequest) -> CliRpcResponse:
586
640
  # Update last request time for timeout functionality
587
641
  self._last_request_time = time.time()
@@ -596,7 +650,7 @@ class RemoteExecutionClient:
596
650
  )
597
651
  else:
598
652
  raw_result = await execute_tool( # type: ignore[assignment]
599
- request.request.tool_input, self.working_directory
653
+ request.request.tool_input, self.working_directory, self
600
654
  )
601
655
  tool_result = truncate_result(raw_result)
602
656
  return CliRpcResponse(
@@ -623,7 +677,9 @@ class RemoteExecutionClient:
623
677
  )
624
678
  )
625
679
  else:
626
- coros.append(execute_tool(tool_input, self.working_directory))
680
+ coros.append(
681
+ execute_tool(tool_input, self.working_directory, self)
682
+ )
627
683
 
628
684
  results: list[ToolResultType | BaseException] = await asyncio.gather(
629
685
  *coros, return_exceptions=True
@@ -149,7 +149,10 @@ async def execute_shell_streaming( # noqa: PLR0915
149
149
  def on_timeout() -> None:
150
150
  nonlocal timed_out
151
151
  timed_out = True
152
- process.kill()
152
+ try:
153
+ process.kill()
154
+ except ProcessLookupError:
155
+ pass
153
156
 
154
157
  try:
155
158
  halt_task = asyncio.create_task(monitor_halt()) if should_halt else None
@@ -3,6 +3,7 @@ import uuid
3
3
  from collections.abc import Callable
4
4
  from pathlib import Path
5
5
  from time import time
6
+ from typing import TYPE_CHECKING
6
7
 
7
8
  from anyio import Path as AsyncPath
8
9
 
@@ -19,6 +20,7 @@ from exponent.core.remote_execution.cli_rpc_types import (
19
20
  GrepToolResult,
20
21
  ListToolInput,
21
22
  ListToolResult,
23
+ ReadToolArtifactResult,
22
24
  ReadToolInput,
23
25
  ReadToolResult,
24
26
  ToolInputType,
@@ -26,6 +28,9 @@ from exponent.core.remote_execution.cli_rpc_types import (
26
28
  WriteToolInput,
27
29
  WriteToolResult,
28
30
  )
31
+
32
+ if TYPE_CHECKING:
33
+ from exponent.core.remote_execution.client import RemoteExecutionClient
29
34
  from exponent.core.remote_execution.code_execution import (
30
35
  execute_code_streaming,
31
36
  )
@@ -45,10 +50,12 @@ logger = logging.getLogger(__name__)
45
50
 
46
51
 
47
52
  async def execute_tool(
48
- tool_input: ToolInputType, working_directory: str
53
+ tool_input: ToolInputType,
54
+ working_directory: str,
55
+ upload_client: "RemoteExecutionClient | None" = None,
49
56
  ) -> ToolResultType:
50
57
  if isinstance(tool_input, ReadToolInput):
51
- return await execute_read_file(tool_input, working_directory)
58
+ return await execute_read_file(tool_input, working_directory, upload_client)
52
59
  elif isinstance(tool_input, WriteToolInput):
53
60
  return await execute_write_file(tool_input, working_directory)
54
61
  elif isinstance(tool_input, ListToolInput):
@@ -69,9 +76,20 @@ def truncate_result[T: ToolResultType](tool_result: T) -> T:
69
76
  return truncate_tool_result(tool_result)
70
77
 
71
78
 
72
- async def execute_read_file( # noqa: PLR0911
73
- tool_input: ReadToolInput, working_directory: str
74
- ) -> ReadToolResult | ErrorToolResult:
79
+ def is_image_file(file_path: str) -> tuple[bool, str | None]:
80
+ ext = Path(file_path).suffix.lower()
81
+ if ext == ".png":
82
+ return (True, "image/png")
83
+ elif ext in [".jpg", ".jpeg"]:
84
+ return (True, "image/jpeg")
85
+ return (False, None)
86
+
87
+
88
+ async def execute_read_file( # noqa: PLR0911, PLR0915
89
+ tool_input: ReadToolInput,
90
+ working_directory: str,
91
+ upload_client: "RemoteExecutionClient | None" = None,
92
+ ) -> ReadToolResult | ReadToolArtifactResult | ErrorToolResult:
75
93
  # Validate absolute path requirement
76
94
  if not tool_input.file_path.startswith("/"):
77
95
  return ErrorToolResult(
@@ -82,16 +100,44 @@ async def execute_read_file( # noqa: PLR0911
82
100
  offset = tool_input.offset if tool_input.offset is not None else 0
83
101
  limit = tool_input.limit if tool_input.limit is not None else 2000
84
102
 
85
- if offset < 0:
86
- return ErrorToolResult(
87
- error_message=f"Offset must be non-negative, got: {offset}"
88
- )
89
-
90
103
  if limit <= 0:
91
104
  return ErrorToolResult(error_message=f"Limit must be positive, got: {limit}")
92
105
 
93
106
  file = AsyncPath(working_directory, tool_input.file_path)
94
107
 
108
+ # Check if this is an image file and we have an upload client
109
+ is_image, media_type = is_image_file(tool_input.file_path)
110
+ if is_image and media_type and upload_client is not None:
111
+ try:
112
+ import aiohttp
113
+
114
+ file_name = Path(tool_input.file_path).name
115
+ s3_key = f"images/{uuid.uuid4()}/{file_name}"
116
+
117
+ upload_response = await upload_client.request_upload_url(s3_key, media_type)
118
+
119
+ async with aiohttp.ClientSession() as session:
120
+ f = await file.open("rb")
121
+ async with f:
122
+ file_data = await f.read()
123
+ async with session.put(
124
+ upload_response.upload_url,
125
+ data=file_data,
126
+ headers={"Content-Type": media_type},
127
+ ) as resp:
128
+ if resp.status != 200:
129
+ raise RuntimeError(
130
+ f"Upload failed with status {resp.status}"
131
+ )
132
+
133
+ return ReadToolArtifactResult(
134
+ s3_uri=upload_response.s3_uri,
135
+ file_path=tool_input.file_path,
136
+ media_type=media_type,
137
+ )
138
+ except Exception as e:
139
+ return ErrorToolResult(error_message=f"Failed to upload image to S3: {e!s}")
140
+
95
141
  try:
96
142
  exists = await file.exists()
97
143
  except (OSError, PermissionError) as e:
@@ -138,8 +184,8 @@ async def execute_read_file( # noqa: PLR0911
138
184
  content_lines = content.splitlines(keepends=True)
139
185
  total_lines = len(content_lines)
140
186
 
141
- # Handle offset beyond file length
142
- if offset >= total_lines:
187
+ # Handle offset beyond file length for positive offsets
188
+ if offset >= 0 and offset >= total_lines:
143
189
  return ReadToolResult(
144
190
  content="",
145
191
  num_lines=0,
@@ -148,8 +194,26 @@ async def execute_read_file( # noqa: PLR0911
148
194
  metadata=metadata,
149
195
  )
150
196
 
151
- # Apply offset and limit
152
- content_lines = content_lines[offset : offset + limit]
197
+ # Use Python's native slicing - it handles negative offsets naturally
198
+ # Handle the case where offset + limit < 0 (can't mix negative and non-negative indices)
199
+ if offset < 0 and offset + limit < 0:
200
+ # Both start and end are negative, use negative end index
201
+ end_index = offset + limit
202
+ elif offset < 0 and offset + limit >= 0:
203
+ # Start is negative but end would be positive/zero, slice to end
204
+ end_index = None
205
+ else:
206
+ # Normal case: both indices are non-negative
207
+ end_index = offset + limit
208
+
209
+ content_lines = content_lines[offset:end_index]
210
+
211
+ # Calculate the actual start line for the result
212
+ if offset < 0:
213
+ # For negative offsets, calculate where we actually started
214
+ actual_start_line = max(0, total_lines + offset)
215
+ else:
216
+ actual_start_line = offset
153
217
 
154
218
  # Apply character-level truncation at line boundaries to ensure consistency
155
219
  # This ensures the content field and num_lines field remain in sync
@@ -186,7 +250,7 @@ async def execute_read_file( # noqa: PLR0911
186
250
  return ReadToolResult(
187
251
  content=final_content,
188
252
  num_lines=num_lines,
189
- start_line=offset,
253
+ start_line=actual_start_line,
190
254
  total_lines=total_lines,
191
255
  metadata=metadata,
192
256
  )
@@ -11,6 +11,7 @@ from exponent.core.remote_execution.cli_rpc_types import (
11
11
  GlobToolResult,
12
12
  GrepToolResult,
13
13
  ListToolResult,
14
+ ReadToolArtifactResult,
14
15
  ReadToolResult,
15
16
  ToolResult,
16
17
  WriteToolResult,
@@ -173,6 +174,14 @@ class TailTruncation(TruncationStrategy):
173
174
  return replace(result, **updates)
174
175
 
175
176
 
177
+ class NoOpTruncation(TruncationStrategy):
178
+ def should_truncate(self, result: ToolResult) -> bool:
179
+ return False
180
+
181
+ def truncate(self, result: ToolResult) -> ToolResult:
182
+ return result
183
+
184
+
176
185
  class StringListTruncation(TruncationStrategy):
177
186
  """Truncation for lists of strings that limits both number of items and individual string length."""
178
187
 
@@ -264,6 +273,7 @@ class StringListTruncation(TruncationStrategy):
264
273
 
265
274
  TRUNCATION_REGISTRY: dict[type[ToolResult], TruncationStrategy] = {
266
275
  ReadToolResult: StringFieldTruncation("content"),
276
+ ReadToolArtifactResult: NoOpTruncation(),
267
277
  WriteToolResult: StringFieldTruncation("message"),
268
278
  BashToolResult: TailTruncation("shell_output"),
269
279
  GrepToolResult: StringListTruncation("matches"),
@@ -52,6 +52,9 @@ class PrReviewWorkflowInput(BaseModel):
52
52
  class SlackWorkflowInput(BaseModel):
53
53
  channel_id: str
54
54
  thread_ts: str
55
+ slack_url: str | None = None
56
+ channel_name: str | None = None
57
+ message_ts: str | None = None
55
58
 
56
59
 
57
60
  WorkflowInput = PrReviewWorkflowInput | SlackWorkflowInput
@@ -216,8 +219,18 @@ class PromptAttachment(BaseModel):
216
219
  prompt_content: str
217
220
 
218
221
 
222
+ class SQLAttachment(BaseModel):
223
+ attachment_type: Literal["sql"] = "sql"
224
+ query_content: str
225
+ query_id: str
226
+
227
+
219
228
  MessageAttachment = Annotated[
220
- FileAttachment | URLAttachment | TableSchemaAttachment | PromptAttachment,
229
+ FileAttachment
230
+ | URLAttachment
231
+ | TableSchemaAttachment
232
+ | PromptAttachment
233
+ | SQLAttachment,
221
234
  Field(discriminator="attachment_type"),
222
235
  ]
223
236
 
@@ -542,30 +555,18 @@ class CLIConnectedState(BaseModel):
542
555
 
543
556
 
544
557
  class DevboxConnectedState(str, Enum):
545
- # TODO: Only needed if we create devbox async
546
- INITIALIZED = "INITIALIZED"
547
558
  # The chat has been initialized, but the devbox is still loading
548
559
  DEVBOX_LOADING = "DEVBOX_LOADING"
549
- # Devbox is ready to use but not tied to a chat
550
- DEVBOX_READY = "DEVBOX_READY"
551
560
  # CLI is connected and running on devbox
552
561
  CONNECTED = "CONNECTED"
553
- # CLI has disconnected
554
- # TODO: what condition?
555
- CLI_DISCONNECTED = "CLI_DISCONNECTED"
556
- # CLI has an error, devbox is running
557
- CLI_ERROR = "CLI_ERROR"
558
562
  # Devbox has an error
559
563
  DEVBOX_ERROR = "DEVBOX_ERROR"
560
564
  # Devbox is going to idle
561
- GOING_TO_IDLE = "GOING_TO_IDLE"
562
- # Devbox is idle
563
- IDLE = "IDLE"
564
- # Devbox is going to idle
565
- RESUMING_FROM_IDLE = "RESUMING_FROM_IDLE"
566
- # Devbox_shutdown
567
- # TODO: In theory our terminal state, do we want to name something different?
568
- DEVBOX_SHUTDOWN = "DEVBOX_SHUTDOWN"
565
+ PAUSING = "PAUSING"
566
+ # Devbox has been paused and is not running
567
+ PAUSED = "PAUSED"
568
+ # Dev box is starting up. Sandbox exists but devbox is not running
569
+ RESUMING = "RESUMING"
569
570
 
570
571
 
571
572
  class CloudConnectedState(BaseModel):
@@ -260,7 +260,7 @@ def upgrade_exponent_in_background(
260
260
  return
261
261
 
262
262
  click.secho(
263
- f"\nUpgrading Exponent from {current_version} to {new_version} (this will take effect next time)\n",
263
+ f"\nUpgrading Indent from {current_version} to {new_version} (this will take effect next time)\n",
264
264
  fg="cyan",
265
265
  bold=True,
266
266
  )
@@ -49,7 +49,7 @@ dev = [
49
49
  "coverage>=7.6.1,<8",
50
50
  "freezegun>=1.5.1,<2",
51
51
  "pytest>=8.3.3,<9",
52
- "pytest-asyncio>=0.24.0,<0.25",
52
+ "pytest-asyncio>=0.24.0,<1.3",
53
53
  "pytest-cov>=5.0.0,<6",
54
54
  "pytest-httpx>=0.31.0",
55
55
  "pytest-xdist>=3.6.1,<4",
@@ -1,111 +0,0 @@
1
- import asyncio
2
- import os
3
- import sys
4
- from typing import cast
5
-
6
- import click
7
-
8
- from exponent.commands.run_commands import run_chat
9
- from exponent.commands.settings import use_settings
10
- from exponent.commands.types import exponent_cli_group
11
- from exponent.core.config import Settings
12
- from exponent.core.remote_execution.client import RemoteExecutionClient, WSDisconnected
13
- from exponent.core.remote_execution.types import (
14
- PrReviewWorkflowInput,
15
- WorkflowTriggerResponse,
16
- )
17
-
18
-
19
- @exponent_cli_group(name="workflow")
20
- def workflow_cli() -> None:
21
- """Workflow commands."""
22
- pass
23
-
24
-
25
- @workflow_cli.group(hidden=True)
26
- def workflow() -> None:
27
- """Workflow management commands."""
28
- pass
29
-
30
-
31
- @workflow.command()
32
- @use_settings
33
- @click.argument("workflow_type", type=click.STRING)
34
- def trigger(settings: Settings, workflow_type: str) -> None:
35
- """Trigger a workflow."""
36
-
37
- if not settings.api_key:
38
- raise click.ClickException(
39
- "No API key found. Use `indent login` to set your API key."
40
- )
41
-
42
- if workflow_type != "pr_review":
43
- raise click.UsageError("Invalid workflow name. Only 'pr_review' is supported.")
44
-
45
- loop = asyncio.get_event_loop()
46
- response = loop.run_until_complete(trigger_pr_review_workflow(settings))
47
-
48
- while True:
49
- result = run_chat(
50
- loop, settings.api_key, response.chat_uuid, settings, None, None, None
51
- )
52
- if result is None or isinstance(result, WSDisconnected):
53
- # NOTE: None here means that handle_connection_changes exited
54
- # first. We should likely have a different message for this.
55
- if result and result.error_message:
56
- click.secho(f"Error: {result.error_message}", fg="red")
57
- sys.exit(10)
58
- else:
59
- click.echo("Disconnected upon user request, shutting down...")
60
- break
61
- else:
62
- raise click.ClickException("Workflow run exited unexpectedly")
63
-
64
-
65
- async def _subprocess_check_output(command: str) -> str:
66
- process = await asyncio.create_subprocess_shell(
67
- command,
68
- stdout=asyncio.subprocess.PIPE,
69
- stderr=asyncio.subprocess.STDOUT,
70
- )
71
- stdout, _ = await process.communicate()
72
-
73
- if process.returncode != 0:
74
- output = stdout.decode().strip()
75
- raise click.ClickException(
76
- f"Command '{command}' failed with exit code {process.returncode}:\n{output}"
77
- )
78
-
79
- return stdout.decode().strip()
80
-
81
-
82
- async def trigger_pr_review_workflow(settings: Settings) -> WorkflowTriggerResponse:
83
- origin_url = await _subprocess_check_output("git ls-remote --get-url origin")
84
- url = origin_url.strip().removesuffix(".git")
85
- remote = url.split(":")[-1]
86
- owner, repo = remote.split("/")[-2:]
87
-
88
- pr_number_str = os.environ.get("PR_NUMBER")
89
- if not pr_number_str:
90
- raise click.ClickException("PR_NUMBER environment variable is not set")
91
- try:
92
- pr_number = int(pr_number_str)
93
- except ValueError:
94
- raise click.ClickException(
95
- "PR_NUMBER environment variable is not a valid integer"
96
- )
97
-
98
- async with RemoteExecutionClient.session(
99
- api_key=cast(str, settings.api_key),
100
- base_url=settings.get_base_api_url(),
101
- base_ws_url=settings.get_base_ws_url(),
102
- working_directory=os.getcwd(),
103
- ) as client:
104
- return await client.trigger_workflow(
105
- workflow_name="pr_review",
106
- workflow_input=PrReviewWorkflowInput(
107
- repo_owner=owner,
108
- repo_name=repo,
109
- pr_number=pr_number,
110
- ),
111
- )
File without changes
File without changes