gemcode 0.3.65__tar.gz → 0.3.67__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 (127) hide show
  1. {gemcode-0.3.65/src/gemcode.egg-info → gemcode-0.3.67}/PKG-INFO +1 -1
  2. {gemcode-0.3.65 → gemcode-0.3.67}/pyproject.toml +1 -1
  3. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/agent.py +24 -0
  4. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/callbacks.py +45 -5
  5. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/config.py +13 -0
  6. gemcode-0.3.67/src/gemcode/dynamic_policy.py +117 -0
  7. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/memory/embedding_memory_service.py +28 -2
  8. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/memory/file_memory_service.py +36 -1
  9. gemcode-0.3.67/src/gemcode/tool_result_store.py +162 -0
  10. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/__init__.py +32 -0
  11. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/bash.py +15 -2
  12. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/filesystem.py +10 -1
  13. gemcode-0.3.67/src/gemcode/tools/repo_map.py +132 -0
  14. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/search.py +10 -1
  15. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/shell.py +10 -2
  16. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/subtask.py +40 -3
  17. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/web.py +14 -2
  18. {gemcode-0.3.65 → gemcode-0.3.67/src/gemcode.egg-info}/PKG-INFO +1 -1
  19. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode.egg-info/SOURCES.txt +3 -0
  20. {gemcode-0.3.65 → gemcode-0.3.67}/LICENSE +0 -0
  21. {gemcode-0.3.65 → gemcode-0.3.67}/MANIFEST.in +0 -0
  22. {gemcode-0.3.65 → gemcode-0.3.67}/README.md +0 -0
  23. {gemcode-0.3.65 → gemcode-0.3.67}/setup.cfg +0 -0
  24. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/__init__.py +0 -0
  25. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/__main__.py +0 -0
  26. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/audit.py +0 -0
  27. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/autocompact.py +0 -0
  28. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/capability_routing.py +0 -0
  29. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/cli.py +0 -0
  30. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/compaction.py +0 -0
  31. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/computer_use/__init__.py +0 -0
  32. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/computer_use/browser_computer.py +0 -0
  33. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/context_budget.py +0 -0
  34. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/context_warning.py +0 -0
  35. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/credentials.py +0 -0
  36. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/hitl_session.py +0 -0
  37. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/hooks.py +0 -0
  38. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/intent_classifier.py +0 -0
  39. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/interactions.py +0 -0
  40. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/invoke.py +0 -0
  41. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/kairos_daemon.py +0 -0
  42. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/limits.py +0 -0
  43. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/live_audio_engine.py +0 -0
  44. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/logging_config.py +0 -0
  45. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/mcp_loader.py +0 -0
  46. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/memory/__init__.py +0 -0
  47. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/modality_tools.py +0 -0
  48. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/model_errors.py +0 -0
  49. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/model_routing.py +0 -0
  50. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/openapi_loader.py +0 -0
  51. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/paths.py +0 -0
  52. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/permissions.py +0 -0
  53. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/plugins/__init__.py +0 -0
  54. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  55. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  56. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/pricing.py +0 -0
  57. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/prompt_suggestions.py +0 -0
  58. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/__init__.py +0 -0
  59. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/config.py +0 -0
  60. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/deps.py +0 -0
  61. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/engine.py +0 -0
  62. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/stop_hooks.py +0 -0
  63. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/token_budget.py +0 -0
  64. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/query/transitions.py +0 -0
  65. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/refine.py +0 -0
  66. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/repl_commands.py +0 -0
  67. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/repl_slash.py +0 -0
  68. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/review_agent.py +0 -0
  69. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/session_runtime.py +0 -0
  70. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/session_store.py +0 -0
  71. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/slash_commands.py +0 -0
  72. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/thinking.py +0 -0
  73. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tool_prompt_manifest.py +0 -0
  74. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tool_registry.py +0 -0
  75. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/browser.py +0 -0
  76. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/edit.py +0 -0
  77. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/notebook.py +0 -0
  78. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/notes.py +0 -0
  79. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/shell_gate.py +0 -0
  80. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/tasks.py +0 -0
  81. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/think.py +0 -0
  82. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/todo.py +0 -0
  83. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools/web_search.py +0 -0
  84. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tools_inspector.py +0 -0
  85. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/trust.py +0 -0
  86. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tui/input_handler.py +0 -0
  87. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tui/scrollback.py +0 -0
  88. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tui/spinner.py +0 -0
  89. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tui/welcome_banner.py +0 -0
  90. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/tui/welcome_rich.py +0 -0
  91. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/version.py +0 -0
  92. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/vertex.py +0 -0
  93. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/web/__init__.py +0 -0
  94. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/web/claude_sse_adapter.py +0 -0
  95. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/web/terminal_repl.py +0 -0
  96. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode/workspace_hints.py +0 -0
  97. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode.egg-info/dependency_links.txt +0 -0
  98. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode.egg-info/entry_points.txt +0 -0
  99. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode.egg-info/requires.txt +0 -0
  100. {gemcode-0.3.65 → gemcode-0.3.67}/src/gemcode.egg-info/top_level.txt +0 -0
  101. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_agent_instruction.py +0 -0
  102. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_autocompact.py +0 -0
  103. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_capability_routing.py +0 -0
  104. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_claude_web_adapter_sse.py +0 -0
  105. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_cli_init.py +0 -0
  106. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_computer_use_permissions.py +0 -0
  107. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_context_budget.py +0 -0
  108. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_context_warning.py +0 -0
  109. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_credentials.py +0 -0
  110. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_interactive_permission_ask.py +0 -0
  111. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_kairos_scheduler.py +0 -0
  112. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_modality_tools.py +0 -0
  113. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_model_error_retry.py +0 -0
  114. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_model_errors.py +0 -0
  115. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_model_routing.py +0 -0
  116. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_paths.py +0 -0
  117. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_permissions.py +0 -0
  118. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_prompt_suggestions.py +0 -0
  119. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_repl_commands.py +0 -0
  120. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_repl_slash.py +0 -0
  121. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_slash_commands.py +0 -0
  122. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_thinking_config.py +0 -0
  123. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_token_budget.py +0 -0
  124. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_tool_context_circulation.py +0 -0
  125. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_tools.py +0 -0
  126. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_tools_inspector.py +0 -0
  127. {gemcode-0.3.65 → gemcode-0.3.67}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.65
3
+ Version: 0.3.67
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.65"
7
+ version = "0.3.67"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -462,6 +462,14 @@ key_combination(["control+c"]) # copy
462
462
 
463
463
 
464
464
  def build_instruction(cfg: GemCodeConfig) -> str:
465
+ import os as _os
466
+ verbose_tools_guide = _os.environ.get("GEMCODE_VERBOSE_INSTRUCTIONS", "").lower() in (
467
+ "1",
468
+ "true",
469
+ "yes",
470
+ "on",
471
+ )
472
+
465
473
  base = f"""You are GemCode, an expert software engineering agent powered by Google Gemini.
466
474
  You run locally via the GemCode CLI. You are the same agent the user launched — not a hosted portal.
467
475
 
@@ -509,6 +517,21 @@ You have native deep thinking capability — use it actively:
509
517
  - When something fails, diagnose (re-read the error, check assumptions) before switching strategy. Do not repeat the same failed call.
510
518
  - When asked to analyse or explain something: read the actual files, produce concrete findings, not hypotheses.
511
519
 
520
+ ## Token efficiency without losing intelligence
521
+ - Prefer **small, targeted tool outputs** by default (saves context, improves accuracy).
522
+ - If a tool output was **offloaded** (you see a `tool_result:<sha>` reference), and you need details, call `load_tool_result(ref)` and extract only the relevant slice.
523
+
524
+ ## Tool selection guide (only when needed)
525
+
526
+ Keep tool usage minimal. Prefer short, targeted calls and keep tool outputs small.
527
+ If you need more tool usage examples, set `GEMCODE_VERBOSE_INSTRUCTIONS=1`.
528
+
529
+ """
530
+
531
+ if not verbose_tools_guide:
532
+ return base.strip() + "\n"
533
+
534
+ tool_guide = r"""
512
535
  ## Tool selection guide
513
536
 
514
537
  ### Shell execution (critical — use these for real work)
@@ -864,6 +887,7 @@ def build_root_agent(
864
887
  pre-built list that excludes run_subtask itself, preventing recursion).
865
888
  When set, build_function_tools() is NOT called.
866
889
  """
890
+ return (base + tool_guide).strip() + "\n"
867
891
  if _tools is not None:
868
892
  tools = list(_tools)
869
893
  else:
@@ -311,20 +311,50 @@ def make_after_tool_callback(cfg: GemCodeConfig):
311
311
  tool_response: dict,
312
312
  ) -> dict | None:
313
313
  truncated = False
314
- if isinstance(tool_response, dict) and getattr(cfg, "tool_result_max_chars", 0) > 0:
314
+ offloaded = False
315
+ name = getattr(tool, "name", None) or ""
316
+
317
+ # Offload oversized tool outputs to disk (stable refs) before truncation.
318
+ # Dynamic caps for tool inline payload size.
319
+ effective_tool_chars = int(getattr(cfg, "tool_result_max_chars", 0) or 0)
320
+ try:
321
+ from gemcode.dynamic_policy import get_dynamic_caps
322
+ effective_tool_chars = get_dynamic_caps(cfg).tool_inline_chars
323
+ except Exception:
324
+ pass
325
+
326
+ if (
327
+ isinstance(tool_response, dict)
328
+ and getattr(cfg, "tool_result_offload_enabled", False)
329
+ and effective_tool_chars > 0
330
+ ):
331
+ try:
332
+ from gemcode.tool_result_store import maybe_offload_tool_result
333
+ new_payload, did = maybe_offload_tool_result(
334
+ project_root=cfg.project_root,
335
+ tool_name=name,
336
+ payload=tool_response,
337
+ max_inline_chars=int(effective_tool_chars),
338
+ )
339
+ if did and isinstance(new_payload, dict):
340
+ tool_response = new_payload
341
+ offloaded = True
342
+ except Exception:
343
+ pass
344
+
345
+ if isinstance(tool_response, dict) and effective_tool_chars > 0:
315
346
  new_d, did = truncate_tool_result_dict(
316
- tool_response, int(cfg.tool_result_max_chars)
347
+ tool_response, int(effective_tool_chars)
317
348
  )
318
349
  if did:
319
350
  tool_response = new_d
320
351
  truncated = True
321
- name = getattr(tool, "name", None) or ""
322
352
  if tool_context is None:
323
- return tool_response if truncated else None
353
+ return tool_response if (truncated or offloaded) else None
324
354
  try:
325
355
  st = tool_context.state
326
356
  except Exception:
327
- return tool_response if truncated else None
357
+ return tool_response if (truncated or offloaded) else None
328
358
  err = isinstance(tool_response, dict) and tool_response.get("error")
329
359
  err_kind = (
330
360
  isinstance(tool_response, dict) and tool_response.get("error_kind")
@@ -409,6 +439,8 @@ def make_after_tool_callback(cfg: GemCodeConfig):
409
439
  pass
410
440
  if truncated:
411
441
  return tool_response
442
+ if offloaded:
443
+ return tool_response
412
444
  return None
413
445
 
414
446
  return after_tool
@@ -508,6 +540,14 @@ def make_after_model_callback(cfg: GemCodeConfig):
508
540
  st[_LAST_PROMPT_TOKENS] = pt
509
541
  st[_LAST_CONTEXT_PCT] = cw.get("percent_left")
510
542
  st[_LAST_CONTEXT_LEVEL] = level
543
+ # Expose to tool layer (dynamic token policy).
544
+ try:
545
+ pct = cw.get("percent_left")
546
+ if isinstance(pct, int):
547
+ object.__setattr__(cfg, "_context_percent_left", pct)
548
+ object.__setattr__(cfg, "_context_alert_level", int(level))
549
+ except Exception:
550
+ pass
511
551
  append_audit(
512
552
  cfg.project_root,
513
553
  {
@@ -130,6 +130,19 @@ class GemCodeConfig:
130
130
  int(os.environ.get("GEMCODE_TOOL_RESULT_MAX_CHARS", "12000")),
131
131
  )
132
132
  )
133
+
134
+ # When enabled, oversized tool outputs are offloaded to disk under
135
+ # .gemcode/tool-results/ and replaced in history with stable refs + previews.
136
+ # This reduces context bloat and improves prompt-cache stability.
137
+ tool_result_offload_enabled: bool = field(
138
+ default_factory=lambda: _truthy_env("GEMCODE_TOOL_RESULT_OFFLOAD", default=True)
139
+ )
140
+
141
+ # Dynamic token policy: adapt tool output caps to context pressure so we stay
142
+ # cheap when context is tight, but remain evidence-rich when there's room.
143
+ dynamic_token_policy: bool = field(
144
+ default_factory=lambda: _truthy_env("GEMCODE_DYNAMIC_TOKEN_POLICY", default=True)
145
+ )
133
146
  # Trim oldest text in llm_request.contents when over budget (see context_budget.py).
134
147
  context_shrink_enabled: bool = field(
135
148
  default_factory=lambda: _truthy_env("GEMCODE_CONTEXT_SHRINK", default=True)
@@ -0,0 +1,117 @@
1
+ """
2
+ Dynamic token budgeting / caps.
3
+
4
+ Optimization must not make the agent dumb:
5
+ - When context pressure is low, allow richer tool outputs and wider reads.
6
+ - When context pressure is high, tighten caps and offload aggressively.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ def _truthy(v: Any, *, default: bool = False) -> bool:
16
+ if v is None:
17
+ return default
18
+ if isinstance(v, bool):
19
+ return v
20
+ if isinstance(v, str):
21
+ return v.lower() in ("1", "true", "yes", "on")
22
+ return bool(v)
23
+
24
+
25
+ def _pct_left(cfg) -> int | None:
26
+ try:
27
+ v = getattr(cfg, "_context_percent_left", None)
28
+ if isinstance(v, int):
29
+ return v
30
+ except Exception:
31
+ return None
32
+ return None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class DynamicCaps:
37
+ tool_inline_chars: int
38
+ read_file_max_bytes: int
39
+ web_fetch_max_chars: int
40
+ bash_stdout_chars: int
41
+ bash_stderr_chars: int
42
+ run_stdout_chars: int
43
+ run_stderr_chars: int
44
+ grep_max_matches: int
45
+
46
+
47
+ def get_dynamic_caps(cfg) -> DynamicCaps:
48
+ """
49
+ Compute caps based on current context pressure.
50
+
51
+ Policy:
52
+ - Healthy (>=45% left): generous caps (better evidence, less re-asking).
53
+ - Warning (20-44%): moderate caps.
54
+ - Tight (<20%): strict caps + prefer offload.
55
+ """
56
+ # cfg can be None in some tool contexts; treat as enabled with defaults.
57
+ enabled = _truthy(getattr(cfg, "dynamic_token_policy", True) if cfg is not None else True, default=True)
58
+ if not enabled:
59
+ # Essentially "no-op" high caps; tools still apply their explicit maxes.
60
+ return DynamicCaps(
61
+ tool_inline_chars=int(getattr(cfg, "tool_result_max_chars", 12000) or 12000),
62
+ read_file_max_bytes=200_000,
63
+ web_fetch_max_chars=40_000,
64
+ bash_stdout_chars=80_000,
65
+ bash_stderr_chars=20_000,
66
+ run_stdout_chars=50_000,
67
+ run_stderr_chars=50_000,
68
+ grep_max_matches=80,
69
+ )
70
+
71
+ pct = _pct_left(cfg) if cfg is not None else None
72
+ if pct is None:
73
+ pct = 35
74
+
75
+ # Base knobs from config (so users can still tune globally).
76
+ base_tool = int(getattr(cfg, "tool_result_max_chars", 12000) or 12000) if cfg is not None else 12000
77
+ base_tool = max(1000, base_tool)
78
+
79
+ if pct >= 45:
80
+ mult = 1.4
81
+ return DynamicCaps(
82
+ tool_inline_chars=min(24_000, int(base_tool * mult)),
83
+ read_file_max_bytes=140_000,
84
+ web_fetch_max_chars=30_000,
85
+ bash_stdout_chars=30_000,
86
+ bash_stderr_chars=15_000,
87
+ run_stdout_chars=30_000,
88
+ run_stderr_chars=30_000,
89
+ grep_max_matches=60,
90
+ )
91
+
92
+ if pct >= 20:
93
+ mult = 1.0
94
+ return DynamicCaps(
95
+ tool_inline_chars=min(18_000, int(base_tool * mult)),
96
+ read_file_max_bytes=80_000,
97
+ web_fetch_max_chars=20_000,
98
+ bash_stdout_chars=20_000,
99
+ bash_stderr_chars=10_000,
100
+ run_stdout_chars=20_000,
101
+ run_stderr_chars=20_000,
102
+ grep_max_matches=40,
103
+ )
104
+
105
+ # Tight
106
+ mult = 0.6
107
+ return DynamicCaps(
108
+ tool_inline_chars=max(2000, int(base_tool * mult)),
109
+ read_file_max_bytes=35_000,
110
+ web_fetch_max_chars=10_000,
111
+ bash_stdout_chars=10_000,
112
+ bash_stderr_chars=8_000,
113
+ run_stdout_chars=10_000,
114
+ run_stderr_chars=10_000,
115
+ grep_max_matches=20,
116
+ )
117
+
@@ -54,6 +54,28 @@ def _concat_text(content: Any) -> str:
54
54
  return "\n".join(pieces)
55
55
 
56
56
 
57
+ def _distill_memory_text(text: str, *, max_chars: int = 1200) -> str:
58
+ t = (text or "").strip()
59
+ if not t:
60
+ return ""
61
+ t = t[:50_000]
62
+ lines = [ln.strip() for ln in t.splitlines() if ln.strip()]
63
+ keep: list[str] = []
64
+ for ln in lines:
65
+ if any(x in ln for x in (".py", ".ts", ".tsx", ".js", ".json", ".yml", ".yaml", "src/", "gemcode/")):
66
+ keep.append(ln)
67
+ elif ln.startswith(("-", "*")) and len(ln) <= 200:
68
+ keep.append(ln)
69
+ elif any(k in ln.lower() for k in ("fix", "bug", "root cause", "decision", "constraint", "todo", "note")):
70
+ keep.append(ln)
71
+ if sum(len(x) + 1 for x in keep) >= max_chars:
72
+ break
73
+ if not keep:
74
+ return t[:max_chars]
75
+ out = "\n".join(keep)
76
+ return out[:max_chars]
77
+
78
+
57
79
  def _cosine_similarity(a: list[float], b: list[float]) -> float:
58
80
  if not a or not b or len(a) != len(b):
59
81
  return -1.0
@@ -166,6 +188,9 @@ class EmbeddingFileMemoryService(BaseMemoryService):
166
188
  text = _concat_text(content)
167
189
  if not text.strip():
168
190
  continue
191
+ distilled = _distill_memory_text(text)
192
+ if not distilled.strip():
193
+ continue
169
194
 
170
195
  ev_id = getattr(ev, "id", None)
171
196
  if not isinstance(ev_id, str) or not ev_id:
@@ -176,7 +201,7 @@ class EmbeddingFileMemoryService(BaseMemoryService):
176
201
  ts = getattr(ev, "timestamp", None)
177
202
  ts_out = ts if isinstance(ts, str) else None
178
203
 
179
- truncated = text[: self.embedding_max_chars]
204
+ truncated = distilled[: self.embedding_max_chars]
180
205
  rec: dict[str, Any] = {
181
206
  "id": ev_id,
182
207
  "app_name": app_name,
@@ -184,7 +209,8 @@ class EmbeddingFileMemoryService(BaseMemoryService):
184
209
  "session_id": session_id,
185
210
  "author": author if isinstance(author, str) else None,
186
211
  "timestamp": ts_out,
187
- "text": text,
212
+ "text": distilled,
213
+ "raw_truncated": text[:4000],
188
214
  "embedding_text": truncated,
189
215
  "embedding": None,
190
216
  }
@@ -49,6 +49,37 @@ def _concat_text(content: Any) -> str:
49
49
  return "\n".join(pieces)
50
50
 
51
51
 
52
+ def _distill_memory_text(text: str, *, max_chars: int = 1200) -> str:
53
+ """
54
+ Distill verbose conversational text into a compact memory payload.
55
+
56
+ This is deliberately non-LLM (fast, deterministic) and focuses on:
57
+ - explicit decisions / constraints
58
+ - paths / symbols / commands
59
+ - short summaries
60
+ """
61
+ t = (text or "").strip()
62
+ if not t:
63
+ return ""
64
+ # Keep only the first N chars; then try to keep high-signal lines.
65
+ t = t[:50_000]
66
+ lines = [ln.strip() for ln in t.splitlines() if ln.strip()]
67
+ keep: list[str] = []
68
+ for ln in lines:
69
+ if any(x in ln for x in (".py", ".ts", ".tsx", ".js", ".json", ".yml", ".yaml", "src/", "gemcode/")):
70
+ keep.append(ln)
71
+ elif ln.startswith(("-", "*")) and len(ln) <= 200:
72
+ keep.append(ln)
73
+ elif any(k in ln.lower() for k in ("fix", "bug", "root cause", "decision", "constraint", "todo", "note")):
74
+ keep.append(ln)
75
+ if sum(len(x) + 1 for x in keep) >= max_chars:
76
+ break
77
+ if not keep:
78
+ return t[:max_chars]
79
+ out = "\n".join(keep)
80
+ return out[:max_chars]
81
+
82
+
52
83
  class FileMemoryService(BaseMemoryService):
53
84
  """JSONL-backed memory service with naive keyword matching."""
54
85
 
@@ -108,6 +139,9 @@ class FileMemoryService(BaseMemoryService):
108
139
  text = _concat_text(content)
109
140
  if not text.strip():
110
141
  continue
142
+ distilled = _distill_memory_text(text)
143
+ if not distilled.strip():
144
+ continue
111
145
 
112
146
  ev_id = getattr(ev, "id", None)
113
147
  if not isinstance(ev_id, str) or not ev_id:
@@ -127,7 +161,8 @@ class FileMemoryService(BaseMemoryService):
127
161
  "session_id": session_id,
128
162
  "author": author,
129
163
  "timestamp": ts_out,
130
- "text": text,
164
+ "text": distilled,
165
+ "raw_truncated": text[:4000],
131
166
  }
132
167
  )
133
168
  existing_ids.add(ev_id)
@@ -0,0 +1,162 @@
1
+ """
2
+ Disk-backed storage for oversized tool outputs.
3
+
4
+ Why:
5
+ - Large tool outputs (stdout, file contents, web pages) are the biggest driver of
6
+ context bloat and cache misses in long agent sessions.
7
+ - Instead of truncating blobs inline (which still mutates history repeatedly),
8
+ we store the full payload on disk and replace it with a stable reference +
9
+ short preview. This matches OpenClaude's "tool result storage" pattern.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ _REF_PREFIX = "tool_result:"
21
+
22
+
23
+ def _store_dir(project_root: Path) -> Path:
24
+ d = project_root / ".gemcode" / "tool-results"
25
+ d.mkdir(parents=True, exist_ok=True)
26
+ return d
27
+
28
+
29
+ def _sha256_bytes(b: bytes) -> str:
30
+ h = hashlib.sha256()
31
+ h.update(b)
32
+ return h.hexdigest()
33
+
34
+
35
+ def _preview(text: str, max_chars: int) -> str:
36
+ if max_chars <= 0:
37
+ return ""
38
+ if len(text) <= max_chars:
39
+ return text
40
+ if max_chars <= 40:
41
+ return text[:max_chars]
42
+ return text[: max_chars - 20] + "\n… [offloaded; preview truncated]\n"
43
+
44
+
45
+ def offload_text(
46
+ *,
47
+ project_root: Path,
48
+ tool_name: str,
49
+ field: str,
50
+ text: str,
51
+ preview_max_chars: int,
52
+ ) -> dict[str, Any]:
53
+ """
54
+ Persist `text` to disk and return a compact reference dict.
55
+
56
+ The ref is content-addressed (sha256 of bytes) so repeated identical outputs
57
+ map to the same ref, improving cache stability.
58
+ """
59
+ b = text.encode("utf-8", errors="replace")
60
+ sha = _sha256_bytes(b)
61
+ ref = f"{_REF_PREFIX}{sha}"
62
+ p = _store_dir(project_root) / f"{sha}.txt"
63
+ if not p.exists():
64
+ # Write once; keep deterministic content for stable cache behavior.
65
+ p.write_bytes(b)
66
+ meta = {
67
+ "ref": ref,
68
+ "sha256": sha,
69
+ "tool": tool_name,
70
+ "field": field,
71
+ "bytes": len(b),
72
+ "chars": len(text),
73
+ "created_at": int(time.time()),
74
+ }
75
+ ( _store_dir(project_root) / f"{sha}.json" ).write_text(
76
+ json.dumps(meta, ensure_ascii=False, indent=2),
77
+ encoding="utf-8",
78
+ errors="replace",
79
+ )
80
+ return {
81
+ "offloaded": True,
82
+ "ref": ref,
83
+ "preview": _preview(text, preview_max_chars),
84
+ "chars": len(text),
85
+ "hint": "Use load_tool_result(ref) to view the full content.",
86
+ }
87
+
88
+
89
+ def maybe_offload_tool_result(
90
+ *,
91
+ project_root: Path,
92
+ tool_name: str,
93
+ payload: Any,
94
+ max_inline_chars: int,
95
+ ) -> tuple[Any, bool]:
96
+ """
97
+ Walk a tool-result payload and offload large text fields.
98
+
99
+ Returns (new_payload, changed).
100
+ """
101
+ if max_inline_chars <= 0:
102
+ return payload, False
103
+
104
+ changed = False
105
+
106
+ def _walk(obj: Any, *, field: str) -> Any:
107
+ nonlocal changed
108
+ if isinstance(obj, str) and len(obj) > max_inline_chars:
109
+ changed = True
110
+ return offload_text(
111
+ project_root=project_root,
112
+ tool_name=tool_name,
113
+ field=field,
114
+ text=obj,
115
+ preview_max_chars=max_inline_chars,
116
+ )
117
+
118
+ if isinstance(obj, list):
119
+ out_list: list[Any] = []
120
+ for i, item in enumerate(obj):
121
+ out_list.append(_walk(item, field=f"{field}[{i}]"))
122
+ if out_list != obj:
123
+ changed = True
124
+ return out_list
125
+
126
+ if isinstance(obj, dict):
127
+ out_dict: dict[str, Any] = {}
128
+ for k, v in obj.items():
129
+ out_dict[k] = _walk(v, field=str(k))
130
+ if out_dict != obj:
131
+ changed = True
132
+ return out_dict
133
+
134
+ return obj
135
+
136
+ # Only dict payloads are expected from our tools, but handle any.
137
+ return _walk(payload, field="payload"), changed
138
+
139
+
140
+ def load_tool_result_text(
141
+ *,
142
+ project_root: Path,
143
+ ref: str,
144
+ max_chars: int = 40_000,
145
+ tail: bool = True,
146
+ ) -> dict[str, Any]:
147
+ if not isinstance(ref, str) or not ref.startswith(_REF_PREFIX):
148
+ return {"error": "Invalid ref. Expected 'tool_result:<sha256>'."}
149
+ sha = ref[len(_REF_PREFIX) :].strip()
150
+ if not sha or any(c not in "0123456789abcdef" for c in sha) or len(sha) < 32:
151
+ return {"error": "Invalid ref sha."}
152
+ p = _store_dir(project_root) / f"{sha}.txt"
153
+ if not p.exists():
154
+ return {"error": f"Not found: {ref}"}
155
+ text = p.read_text(encoding="utf-8", errors="replace")
156
+ truncated = False
157
+ if max_chars is not None and isinstance(max_chars, int) and max_chars > 0:
158
+ if len(text) > max_chars:
159
+ truncated = True
160
+ text = ("… [truncated; showing tail]\n" + text[-max_chars:]) if tail else (text[:max_chars] + "\n… [truncated]")
161
+ return {"ref": ref, "text": text, "truncated": truncated, "chars": len(text)}
162
+
@@ -7,6 +7,7 @@ from gemcode.tools.bash import make_bash_tool
7
7
  from gemcode.tools.edit import make_edit_tools
8
8
  from gemcode.tools.filesystem import make_filesystem_tools
9
9
  from gemcode.tools.notebook import make_notebook_tools
10
+ from gemcode.tools.repo_map import make_repo_map_tool
10
11
  from gemcode.tools.search import make_grep_tool
11
12
  from gemcode.tools.shell import make_run_command
12
13
  from gemcode.tools.subtask import make_run_subtask_tool
@@ -31,6 +32,26 @@ def _get_load_memory_tool():
31
32
  return None
32
33
 
33
34
 
35
+ def _make_load_tool_result_tool(cfg: GemCodeConfig):
36
+ def load_tool_result(ref: str, max_chars: int = 40_000, tail: bool = True) -> dict:
37
+ """
38
+ Load a previously offloaded tool output by reference.
39
+
40
+ Offloaded outputs are created automatically when GEMCODE_TOOL_RESULT_OFFLOAD=1.
41
+ References look like: tool_result:<sha256>.
42
+ """
43
+ from gemcode.tool_result_store import load_tool_result_text
44
+
45
+ return load_tool_result_text(
46
+ project_root=cfg.project_root,
47
+ ref=ref,
48
+ max_chars=max_chars,
49
+ tail=tail,
50
+ )
51
+
52
+ return load_tool_result
53
+
54
+
34
55
  def _wrap_long_running(fn):
35
56
  """
36
57
  Wrap a function tool with ADK's LongRunningFunctionTool so that long-running
@@ -60,6 +81,14 @@ def build_function_tools(cfg: GemCodeConfig, *, include_subtask: bool = True) ->
60
81
  web_search = make_web_search_tool()
61
82
  notebook_read, notebook_edit = make_notebook_tools(cfg)
62
83
  list_tasks, kill_task, task_output = make_task_tools(cfg)
84
+ load_tool_result = _make_load_tool_result_tool(cfg)
85
+ repo_map = make_repo_map_tool(cfg)
86
+
87
+ # Attach cfg for dynamic policy inside web_fetch (no cfg param in signature).
88
+ try:
89
+ setattr(web_fetch, "_cfg", cfg)
90
+ except Exception:
91
+ pass
63
92
 
64
93
  # bash and run_command are the most common long-running tools (builds, tests,
65
94
  # installs). Wrap them with LongRunningFunctionTool so ADK can handle slow
@@ -77,6 +106,7 @@ def build_function_tools(cfg: GemCodeConfig, *, include_subtask: bool = True) ->
77
106
  list_directory,
78
107
  glob_files,
79
108
  grep_content,
109
+ repo_map,
80
110
  # Notebooks
81
111
  notebook_read,
82
112
  notebook_edit,
@@ -95,6 +125,8 @@ def build_function_tools(cfg: GemCodeConfig, *, include_subtask: bool = True) ->
95
125
  # Web / research
96
126
  web_search,
97
127
  web_fetch,
128
+ # Tool output offload loader
129
+ load_tool_result,
98
130
  ]
99
131
 
100
132
  # ADK load_memory: explicit on-demand memory search (complements preload_memory).
@@ -182,8 +182,21 @@ def make_bash_tool(cfg: GemCodeConfig):
182
182
  env=env,
183
183
  check=False,
184
184
  )
185
- stdout = proc.stdout[:80_000]
186
- stderr = proc.stderr[:20_000]
185
+ try:
186
+ from gemcode.dynamic_policy import get_dynamic_caps
187
+ caps = get_dynamic_caps(cfg)
188
+ stdout_cap = caps.bash_stdout_chars
189
+ stderr_cap = caps.bash_stderr_chars
190
+ except Exception:
191
+ stdout_cap = 20_000
192
+ stderr_cap = 10_000
193
+
194
+ # Keep more stderr when failing; it is usually the most informative.
195
+ if proc.returncode != 0:
196
+ stderr_cap = max(stderr_cap, 12_000)
197
+
198
+ stdout = proc.stdout[:stdout_cap]
199
+ stderr = proc.stderr[:stderr_cap]
187
200
  result: dict = {
188
201
  "command": command,
189
202
  "cwd": str(exec_cwd.relative_to(root)) if exec_cwd != root else ".",
@@ -16,7 +16,7 @@ def make_filesystem_tools(cfg: GemCodeConfig):
16
16
 
17
17
  def read_file(
18
18
  path: str,
19
- max_bytes: int = 200_000,
19
+ max_bytes: int = 80_000,
20
20
  start_line: int = 1,
21
21
  end_line: int | None = None,
22
22
  ) -> dict:
@@ -43,6 +43,15 @@ def make_filesystem_tools(cfg: GemCodeConfig):
43
43
  return {"error": str(e)}
44
44
  if not p.is_file():
45
45
  return {"error": f"Not a file: {path}"}
46
+
47
+ # Dynamic caps: allow bigger reads when context is healthy, tighten when tight.
48
+ try:
49
+ from gemcode.dynamic_policy import get_dynamic_caps
50
+ caps = get_dynamic_caps(cfg)
51
+ if isinstance(max_bytes, int) and max_bytes > caps.read_file_max_bytes:
52
+ max_bytes = caps.read_file_max_bytes
53
+ except Exception:
54
+ pass
46
55
  total_bytes = p.stat().st_size
47
56
  data = p.read_bytes()
48
57
  text_full = data.decode("utf-8", errors="replace")