gemcode 0.4.22__tar.gz → 0.4.23__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.
- {gemcode-0.4.22/src/gemcode.egg-info → gemcode-0.4.23}/PKG-INFO +1 -1
- {gemcode-0.4.22 → gemcode-0.4.23}/pyproject.toml +1 -1
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/agent_habits.py +13 -1
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/agent_mesh.py +48 -0
- gemcode-0.4.23/src/gemcode/habit_chains.py +219 -0
- gemcode-0.4.23/src/gemcode/habit_runs.py +137 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/invoke.py +9 -6
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/kaira_daemon.py +8 -6
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/trust.py +45 -2
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/fleet_api.py +126 -7
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/server.py +42 -4
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/sse_adapter.py +18 -6
- gemcode-0.4.23/src/gemcode/web/ui_chat_store.py +76 -0
- {gemcode-0.4.22 → gemcode-0.4.23/src/gemcode.egg-info}/PKG-INFO +1 -1
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode.egg-info/SOURCES.txt +6 -0
- gemcode-0.4.23/tests/test_habit_chains.py +57 -0
- gemcode-0.4.23/tests/test_habit_runs.py +58 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_hosted_tenant.py +14 -0
- gemcode-0.4.23/tests/test_sse_confirmation_batch.py +31 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/LICENSE +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/MANIFEST.in +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/README.md +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/setup.cfg +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/__main__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/a2a_bridge.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/agent.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/agent_intelligence.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/agent_triggers.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/audit.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/automations.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/autotune.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/callbacks.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/checkpoints.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/cli.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/codebase_awareness.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/compaction.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/config.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/credentials.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/curated_memory.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/delegation_learning.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/dynamic_policy.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/evals/harness.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/event_bus.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/fleet_reports.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/hooks.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/ide_protocol.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/ide_stdio.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/intent_classifier.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/interactions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/kaira_client.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/kaira_ipc.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/kaira_job_store.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/learning.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/limits.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/modality_tools.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/multimodal_input.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/openapi_loader.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/org.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/output_styles.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/paths.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/permissions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/policy_profile.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/pricing.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/config.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/query_sanitizer.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/refine.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/repl_commands.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/repl_slash.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/review_agent.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/rules.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/self_healing.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/session_runtime.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/session_store.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/session_summariser.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/skills.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/thinking.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tool_result_store.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tool_synthesis.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/automations_tools.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/compress_memory.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/curated_memory.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/notebook.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/notes.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/org_tools.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/repo_map.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/skills.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/tasks.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/user_choice.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/veomem_tools.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools/web_search.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tui/input_handler.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tui/scrollback.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/veomem_bridge.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/version.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/vertex.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/wal.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/chat_skills.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/customize_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/files_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/hitl_bridge.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/preview_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/project_root.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/runtime_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/serve_bind.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/serve_state.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/sessions_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/terminal_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/web_config_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/web_sse_compat.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/web/workspace_panel_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_add_dir.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_agent_habits.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_agent_mesh.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_autocompact.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_automations.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_capability_routing.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_checkpoint_diff_command.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_cli_init.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_compress_memory_tool.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_context_budget.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_context_warning.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_credentials.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_eval_harness_layout.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_event_bus.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_files_api.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_fleet_reports.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_ide_stdio_attachments.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_kaira_ipc_paths.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_kaira_scheduler.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_modality_tools.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_model_errors.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_model_routing.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_multimodal_input.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_output_styles_and_rules.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_paths.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_permissions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_repl_commands.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_repl_slash.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_session_runtime_cache.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_skills.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_slash_commands.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_slash_completion_registry.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_thinking_config.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_token_budget.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_tools.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_web_server.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_web_sse_adapter.py +0 -0
- {gemcode-0.4.22 → gemcode-0.4.23}/tests/test_workspace_hints.py +0 -0
|
@@ -66,6 +66,10 @@ class Habit:
|
|
|
66
66
|
every_seconds: int | None = None # interval in seconds
|
|
67
67
|
cron: str | None = None # "M H * * *" (minute, hour)
|
|
68
68
|
daily_at: str | None = None # "HH:MM"
|
|
69
|
+
# Trigger chain (fires when another habit's mesh job completes)
|
|
70
|
+
trigger_after: str | None = None # upstream habit name
|
|
71
|
+
trigger_on: str = "finished" # finished | failed | any
|
|
72
|
+
trigger_cooldown_s: float = 0.0
|
|
69
73
|
# Metadata
|
|
70
74
|
priority: int = 0
|
|
71
75
|
max_runs: int | None = None # None = unlimited
|
|
@@ -82,6 +86,9 @@ class Habit:
|
|
|
82
86
|
"every_seconds": self.every_seconds,
|
|
83
87
|
"cron": self.cron,
|
|
84
88
|
"daily_at": self.daily_at,
|
|
89
|
+
"trigger_after": self.trigger_after,
|
|
90
|
+
"trigger_on": self.trigger_on,
|
|
91
|
+
"trigger_cooldown_s": self.trigger_cooldown_s,
|
|
85
92
|
"priority": self.priority,
|
|
86
93
|
"max_runs": self.max_runs,
|
|
87
94
|
"run_count": self.run_count,
|
|
@@ -123,6 +130,9 @@ def load_habits(project_root: Path) -> list[Habit]:
|
|
|
123
130
|
every_seconds=item.get("every_seconds"),
|
|
124
131
|
cron=item.get("cron"),
|
|
125
132
|
daily_at=item.get("daily_at"),
|
|
133
|
+
trigger_after=(str(item.get("trigger_after") or "").strip().lower() or None),
|
|
134
|
+
trigger_on=str(item.get("trigger_on") or "finished").strip().lower() or "finished",
|
|
135
|
+
trigger_cooldown_s=float(item.get("trigger_cooldown_s") or 0),
|
|
126
136
|
priority=int(item.get("priority") or 0),
|
|
127
137
|
max_runs=item.get("max_runs"),
|
|
128
138
|
run_count=int(item.get("run_count") or 0),
|
|
@@ -143,9 +153,11 @@ def save_habits(project_root: Path, habits: list[Habit]) -> None:
|
|
|
143
153
|
|
|
144
154
|
|
|
145
155
|
def _is_due(habit: Habit, now_s: float) -> bool:
|
|
146
|
-
"""Check if a habit is due to run."""
|
|
156
|
+
"""Check if a timer-based habit is due to run (trigger-only habits are never due here)."""
|
|
147
157
|
if not habit.enabled:
|
|
148
158
|
return False
|
|
159
|
+
if (habit.trigger_after or "").strip():
|
|
160
|
+
return False
|
|
149
161
|
if habit.max_runs is not None and habit.run_count >= habit.max_runs:
|
|
150
162
|
return False
|
|
151
163
|
|
|
@@ -124,6 +124,13 @@ class AgentMesh:
|
|
|
124
124
|
except Exception:
|
|
125
125
|
pass
|
|
126
126
|
|
|
127
|
+
self._habit_chain_engine = None
|
|
128
|
+
try:
|
|
129
|
+
from gemcode.habit_chains import HabitChainEngine
|
|
130
|
+
self._habit_chain_engine = HabitChainEngine(cfg)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
127
134
|
# Initialize delegation learning
|
|
128
135
|
self._learner = None
|
|
129
136
|
try:
|
|
@@ -213,6 +220,8 @@ class AgentMesh:
|
|
|
213
220
|
|
|
214
221
|
if self._trigger_engine is not None:
|
|
215
222
|
self._trigger_engine.start()
|
|
223
|
+
if self._habit_chain_engine is not None:
|
|
224
|
+
self._habit_chain_engine.start()
|
|
216
225
|
if self._habit_scheduler is not None:
|
|
217
226
|
self._habit_scheduler.start()
|
|
218
227
|
|
|
@@ -227,6 +236,8 @@ class AgentMesh:
|
|
|
227
236
|
self._scheduler_task.cancel()
|
|
228
237
|
if self._trigger_engine is not None:
|
|
229
238
|
self._trigger_engine.stop()
|
|
239
|
+
if self._habit_chain_engine is not None:
|
|
240
|
+
self._habit_chain_engine.stop()
|
|
230
241
|
if self._habit_scheduler is not None:
|
|
231
242
|
self._habit_scheduler.stop()
|
|
232
243
|
# Signal the background loop to exit
|
|
@@ -628,6 +639,23 @@ class AgentMesh:
|
|
|
628
639
|
if isinstance(hm, dict):
|
|
629
640
|
pl["habit"] = hm
|
|
630
641
|
maybe_append_job_report(fleet_root, pl)
|
|
642
|
+
try:
|
|
643
|
+
from gemcode.habit_runs import append_habit_run
|
|
644
|
+
|
|
645
|
+
hm = job.meta.get("habit") if isinstance(job.meta, dict) else None
|
|
646
|
+
if isinstance(hm, dict) and hm.get("name"):
|
|
647
|
+
append_habit_run(
|
|
648
|
+
fleet_root,
|
|
649
|
+
habit_name=str(hm.get("name") or ""),
|
|
650
|
+
agent=str(hm.get("agent") or job.member_name or ""),
|
|
651
|
+
job_id=job.job_id,
|
|
652
|
+
status="finished",
|
|
653
|
+
report=result_text,
|
|
654
|
+
duration_ms=duration_ms,
|
|
655
|
+
session_id=job.session_id,
|
|
656
|
+
)
|
|
657
|
+
except Exception:
|
|
658
|
+
pass
|
|
631
659
|
except Exception:
|
|
632
660
|
pass
|
|
633
661
|
|
|
@@ -667,6 +695,23 @@ class AgentMesh:
|
|
|
667
695
|
if isinstance(hm, dict):
|
|
668
696
|
plf["habit"] = hm
|
|
669
697
|
maybe_append_job_report(fleet_root, plf)
|
|
698
|
+
try:
|
|
699
|
+
from gemcode.habit_runs import append_habit_run
|
|
700
|
+
|
|
701
|
+
hm = job.meta.get("habit") if isinstance(job.meta, dict) else None
|
|
702
|
+
if isinstance(hm, dict) and hm.get("name"):
|
|
703
|
+
append_habit_run(
|
|
704
|
+
fleet_root,
|
|
705
|
+
habit_name=str(hm.get("name") or ""),
|
|
706
|
+
agent=str(hm.get("agent") or job.member_name or ""),
|
|
707
|
+
job_id=job.job_id,
|
|
708
|
+
status="failed",
|
|
709
|
+
report="",
|
|
710
|
+
error=job.error,
|
|
711
|
+
session_id=job.session_id,
|
|
712
|
+
)
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
670
715
|
except Exception:
|
|
671
716
|
pass
|
|
672
717
|
|
|
@@ -959,6 +1004,9 @@ def _sync_mesh_cfg_project_root(mesh: AgentMesh, cfg: GemCodeConfig) -> None:
|
|
|
959
1004
|
if mesh._trigger_engine is not None:
|
|
960
1005
|
mesh._trigger_engine.cfg = mesh.cfg
|
|
961
1006
|
mesh._trigger_engine.reload()
|
|
1007
|
+
if mesh._habit_chain_engine is not None:
|
|
1008
|
+
mesh._habit_chain_engine.cfg = mesh.cfg
|
|
1009
|
+
mesh._habit_chain_engine.reload()
|
|
962
1010
|
if mesh._habit_scheduler is not None:
|
|
963
1011
|
mesh._habit_scheduler.cfg = mesh.cfg
|
|
964
1012
|
if mesh._learner is not None:
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Habit chains — scheduled tasks that fire when another habit completes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gemcode.agent_habits import Habit, load_habits, save_habits
|
|
11
|
+
from gemcode.config import GemCodeConfig
|
|
12
|
+
from gemcode.event_bus import BusMessage, get_bus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _enabled() -> bool:
|
|
16
|
+
return os.environ.get("GEMCODE_HABIT_CHAINS", "1").strip().lower() in (
|
|
17
|
+
"1",
|
|
18
|
+
"true",
|
|
19
|
+
"yes",
|
|
20
|
+
"on",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _status_matches(trigger_on: str, actual: str) -> bool:
|
|
25
|
+
want = (trigger_on or "finished").strip().lower()
|
|
26
|
+
got = (actual or "").strip().lower()
|
|
27
|
+
if want in ("", "any", "*"):
|
|
28
|
+
return True
|
|
29
|
+
return got == want
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_chain_prompt(
|
|
33
|
+
prompt: str,
|
|
34
|
+
*,
|
|
35
|
+
source_habit: str,
|
|
36
|
+
source_status: str,
|
|
37
|
+
source_report: str = "",
|
|
38
|
+
source_error: str = "",
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Substitute template vars from the upstream habit run."""
|
|
41
|
+
body = (source_report or source_error or "").strip()
|
|
42
|
+
out = (prompt or "").replace("{{source_habit}}", source_habit)
|
|
43
|
+
out = out.replace("{{source_status}}", source_status)
|
|
44
|
+
out = out.replace("{{source_report}}", body)
|
|
45
|
+
out = out.replace("{{report}}", body)
|
|
46
|
+
if body and "{{" not in prompt and body not in prompt:
|
|
47
|
+
out = (
|
|
48
|
+
f"{out.rstrip()}\n\n"
|
|
49
|
+
f"---\n"
|
|
50
|
+
f"Output from upstream task `{source_habit}` ({source_status}):\n"
|
|
51
|
+
f"{body}"
|
|
52
|
+
)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def habits_watching(source_name: str, habits: list[Habit] | None = None) -> list[Habit]:
|
|
57
|
+
"""Return enabled trigger-habits that listen for ``source_name`` completions."""
|
|
58
|
+
src = (source_name or "").strip().lower()
|
|
59
|
+
if not src:
|
|
60
|
+
return []
|
|
61
|
+
if habits is None:
|
|
62
|
+
return []
|
|
63
|
+
out: list[Habit] = []
|
|
64
|
+
for h in habits:
|
|
65
|
+
if not h.enabled:
|
|
66
|
+
continue
|
|
67
|
+
after = (h.trigger_after or "").strip().lower()
|
|
68
|
+
if after and after == src:
|
|
69
|
+
out.append(h)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def would_create_habit_cycle(
|
|
74
|
+
habits: list[Habit],
|
|
75
|
+
*,
|
|
76
|
+
name: str,
|
|
77
|
+
trigger_after: str,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""True if adding ``name`` → ``trigger_after`` closes a directed cycle."""
|
|
80
|
+
nm = (name or "").strip().lower()
|
|
81
|
+
after = (trigger_after or "").strip().lower()
|
|
82
|
+
if not nm or not after:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
edges: dict[str, str] = {}
|
|
86
|
+
for h in habits:
|
|
87
|
+
if h.name == nm:
|
|
88
|
+
continue
|
|
89
|
+
ta = (h.trigger_after or "").strip().lower()
|
|
90
|
+
if ta:
|
|
91
|
+
edges[h.name] = ta
|
|
92
|
+
edges[nm] = after
|
|
93
|
+
|
|
94
|
+
visited: set[str] = set()
|
|
95
|
+
stack: set[str] = set()
|
|
96
|
+
|
|
97
|
+
def dfs(node: str) -> bool:
|
|
98
|
+
if node in stack:
|
|
99
|
+
return True
|
|
100
|
+
if node in visited:
|
|
101
|
+
return False
|
|
102
|
+
visited.add(node)
|
|
103
|
+
stack.add(node)
|
|
104
|
+
nxt = edges.get(node)
|
|
105
|
+
if nxt and dfs(nxt):
|
|
106
|
+
return True
|
|
107
|
+
stack.remove(node)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
return dfs(nm)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class HabitChainEngine:
|
|
114
|
+
"""
|
|
115
|
+
When a habit-backed mesh job finishes, enqueue downstream trigger-habits.
|
|
116
|
+
|
|
117
|
+
Watches ``job.report`` on the in-process bus (same as agent triggers).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, cfg: GemCodeConfig) -> None:
|
|
121
|
+
self.cfg = cfg
|
|
122
|
+
self._bus = get_bus()
|
|
123
|
+
self._active = False
|
|
124
|
+
self._last_fired: dict[str, float] = {}
|
|
125
|
+
self._sub = self._bus.subscribe(topic="job.report", callback=self._on_job_report)
|
|
126
|
+
|
|
127
|
+
def start(self) -> None:
|
|
128
|
+
self._active = _enabled()
|
|
129
|
+
|
|
130
|
+
def stop(self) -> None:
|
|
131
|
+
self._active = False
|
|
132
|
+
self._sub.unsubscribe()
|
|
133
|
+
|
|
134
|
+
def reload(self) -> None:
|
|
135
|
+
self._last_fired.clear()
|
|
136
|
+
|
|
137
|
+
async def _on_job_report(self, msg: BusMessage) -> None:
|
|
138
|
+
if not self._active:
|
|
139
|
+
return
|
|
140
|
+
payload = msg.payload if isinstance(msg.payload, dict) else {}
|
|
141
|
+
hab = payload.get("habit")
|
|
142
|
+
if not isinstance(hab, dict):
|
|
143
|
+
return
|
|
144
|
+
source_name = str(hab.get("name") or "").strip().lower()
|
|
145
|
+
if not source_name:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
status = str(payload.get("status") or "").strip().lower()
|
|
149
|
+
habits = load_habits(self.cfg.project_root)
|
|
150
|
+
watchers = habits_watching(source_name, habits)
|
|
151
|
+
if not watchers:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
now = time.time()
|
|
155
|
+
changed = False
|
|
156
|
+
fired: list[str] = []
|
|
157
|
+
|
|
158
|
+
for target in watchers:
|
|
159
|
+
if not _status_matches(target.trigger_on, status):
|
|
160
|
+
continue
|
|
161
|
+
cd = max(0.0, float(target.trigger_cooldown_s or 0))
|
|
162
|
+
last = self._last_fired.get(target.name, 0.0)
|
|
163
|
+
if cd and (now - last) < cd:
|
|
164
|
+
continue
|
|
165
|
+
if target.max_runs is not None and target.run_count >= target.max_runs:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
prompt = render_chain_prompt(
|
|
169
|
+
target.prompt,
|
|
170
|
+
source_habit=source_name,
|
|
171
|
+
source_status=status,
|
|
172
|
+
source_report=str(payload.get("report") or ""),
|
|
173
|
+
source_error=str(payload.get("error") or ""),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
from gemcode.agent_mesh import get_mesh
|
|
178
|
+
|
|
179
|
+
mesh = get_mesh(self.cfg)
|
|
180
|
+
if mesh is None:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
mesh.enqueue(
|
|
184
|
+
prompt=prompt,
|
|
185
|
+
priority=max(target.priority, 1),
|
|
186
|
+
member_name=target.agent,
|
|
187
|
+
meta={
|
|
188
|
+
"habit": {"name": target.name, "agent": target.agent},
|
|
189
|
+
"chain": {
|
|
190
|
+
"after": source_name,
|
|
191
|
+
"on_status": status,
|
|
192
|
+
"source_job_id": str(payload.get("job_id") or ""),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
target.last_run_ms = int(now * 1000)
|
|
197
|
+
target.run_count += 1
|
|
198
|
+
self._last_fired[target.name] = now
|
|
199
|
+
changed = True
|
|
200
|
+
fired.append(target.name)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
if changed:
|
|
205
|
+
save_habits(self.cfg.project_root, habits)
|
|
206
|
+
try:
|
|
207
|
+
await self._bus.publish(
|
|
208
|
+
BusMessage(
|
|
209
|
+
topic="habit.chain",
|
|
210
|
+
from_addr="habit-chain",
|
|
211
|
+
payload={
|
|
212
|
+
"source": source_name,
|
|
213
|
+
"status": status,
|
|
214
|
+
"triggered": fired,
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Persistent audit log for scheduled habit (mesh job) completions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _habit_runs_path(fleet_root: Path) -> Path:
|
|
12
|
+
return fleet_root / ".gemcode" / "habit_runs.jsonl"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def append_habit_run(
|
|
16
|
+
fleet_root: Path,
|
|
17
|
+
*,
|
|
18
|
+
habit_name: str,
|
|
19
|
+
agent: str = "",
|
|
20
|
+
job_id: str = "",
|
|
21
|
+
status: str = "",
|
|
22
|
+
report: str = "",
|
|
23
|
+
error: str = "",
|
|
24
|
+
duration_ms: int | None = None,
|
|
25
|
+
session_id: str = "",
|
|
26
|
+
ts_ms: int | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Append one habit run record (never drained — unlike fleet_reports.jsonl)."""
|
|
29
|
+
nm = (habit_name or "").strip().lower()
|
|
30
|
+
if not nm:
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
p = _habit_runs_path(fleet_root)
|
|
34
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
rec: dict[str, Any] = {
|
|
36
|
+
"ts_ms": int(ts_ms or int(time.time() * 1000)),
|
|
37
|
+
"habit_name": nm,
|
|
38
|
+
"agent": (agent or "").strip(),
|
|
39
|
+
"job_id": (job_id or "").strip(),
|
|
40
|
+
"status": (status or "").strip().lower() or "unknown",
|
|
41
|
+
"report": (report or "").strip()[:12_000],
|
|
42
|
+
"error": (error or "").strip()[:4000],
|
|
43
|
+
"session_id": (session_id or "").strip(),
|
|
44
|
+
}
|
|
45
|
+
if duration_ms is not None:
|
|
46
|
+
rec["duration_ms"] = int(duration_ms)
|
|
47
|
+
line = json.dumps(rec, ensure_ascii=False, default=str) + "\n"
|
|
48
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
49
|
+
f.write(line)
|
|
50
|
+
f.flush()
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _habit_name_from_payload(payload: dict[str, Any]) -> str:
|
|
56
|
+
hab = payload.get("habit")
|
|
57
|
+
if isinstance(hab, dict):
|
|
58
|
+
return str(hab.get("name") or "").strip().lower()
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _record_from_job_report(rec: dict[str, Any]) -> dict[str, Any] | None:
|
|
63
|
+
if str(rec.get("topic") or "") != "job.report":
|
|
64
|
+
return None
|
|
65
|
+
payload = rec.get("payload")
|
|
66
|
+
if not isinstance(payload, dict):
|
|
67
|
+
return None
|
|
68
|
+
habit_name = _habit_name_from_payload(payload)
|
|
69
|
+
if not habit_name:
|
|
70
|
+
return None
|
|
71
|
+
hab = payload.get("habit") if isinstance(payload.get("habit"), dict) else {}
|
|
72
|
+
return {
|
|
73
|
+
"ts_ms": int(rec.get("ts_ms") or int(time.time() * 1000)),
|
|
74
|
+
"habit_name": habit_name,
|
|
75
|
+
"agent": str(hab.get("agent") or payload.get("member") or "").strip(),
|
|
76
|
+
"job_id": str(payload.get("job_id") or "").strip(),
|
|
77
|
+
"status": str(payload.get("status") or "").strip().lower() or "unknown",
|
|
78
|
+
"report": str(payload.get("report") or "").strip()[:12_000],
|
|
79
|
+
"error": str(payload.get("error") or "").strip()[:4000],
|
|
80
|
+
"session_id": str(payload.get("session_id") or "").strip(),
|
|
81
|
+
"duration_ms": payload.get("duration_ms"),
|
|
82
|
+
"pending_inbox": True,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
return []
|
|
89
|
+
try:
|
|
90
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
except OSError:
|
|
92
|
+
return []
|
|
93
|
+
out: list[dict[str, Any]] = []
|
|
94
|
+
for line in raw.splitlines():
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
rec = json.loads(line)
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
continue
|
|
102
|
+
if isinstance(rec, dict):
|
|
103
|
+
out.append(rec)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_habit_runs(
|
|
108
|
+
fleet_root: Path,
|
|
109
|
+
*,
|
|
110
|
+
habit_name: str,
|
|
111
|
+
limit: int = 50,
|
|
112
|
+
) -> list[dict[str, Any]]:
|
|
113
|
+
"""Return recent runs for one habit, newest first. Merges audit log + pending inbox."""
|
|
114
|
+
nm = (habit_name or "").strip().lower()
|
|
115
|
+
if not nm:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
limit = max(1, min(200, int(limit)))
|
|
119
|
+
by_job: dict[str, dict[str, Any]] = {}
|
|
120
|
+
|
|
121
|
+
for rec in _read_jsonl(_habit_runs_path(fleet_root)):
|
|
122
|
+
if str(rec.get("habit_name") or "").strip().lower() != nm:
|
|
123
|
+
continue
|
|
124
|
+
jid = str(rec.get("job_id") or "").strip() or f"run-{rec.get('ts_ms')}"
|
|
125
|
+
by_job[jid] = {k: v for k, v in rec.items() if k != "pending_inbox"}
|
|
126
|
+
|
|
127
|
+
inbox_path = fleet_root / ".gemcode" / "fleet_reports.jsonl"
|
|
128
|
+
for rec in _read_jsonl(inbox_path):
|
|
129
|
+
row = _record_from_job_report(rec)
|
|
130
|
+
if row is None or row["habit_name"] != nm:
|
|
131
|
+
continue
|
|
132
|
+
jid = row["job_id"] or f"pending-{row['ts_ms']}"
|
|
133
|
+
if jid not in by_job:
|
|
134
|
+
by_job[jid] = row
|
|
135
|
+
|
|
136
|
+
runs = sorted(by_job.values(), key=lambda r: int(r.get("ts_ms") or 0), reverse=True)
|
|
137
|
+
return runs[:limit]
|
|
@@ -247,15 +247,18 @@ async def run_turn(
|
|
|
247
247
|
REQUEST_CONFIRMATION_FC = "adk_request_confirmation"
|
|
248
248
|
|
|
249
249
|
def _get_confirmation_requests(events: list) -> list[types.FunctionCall]:
|
|
250
|
-
|
|
251
|
-
for ev in events:
|
|
250
|
+
"""Return confirmation FCs from the last event in the batch that has any."""
|
|
251
|
+
for ev in reversed(events):
|
|
252
252
|
try:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
fcs = [
|
|
254
|
+
fc for fc in (ev.get_function_calls() or [])
|
|
255
|
+
if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC
|
|
256
|
+
]
|
|
257
|
+
if fcs:
|
|
258
|
+
return fcs
|
|
256
259
|
except Exception:
|
|
257
260
|
continue
|
|
258
|
-
return
|
|
261
|
+
return []
|
|
259
262
|
|
|
260
263
|
def _extract_hint_and_tool(fc: types.FunctionCall) -> tuple[str, str]:
|
|
261
264
|
tool_name = "unknown_tool"
|
|
@@ -784,15 +784,17 @@ class KairaDaemon:
|
|
|
784
784
|
)
|
|
785
785
|
|
|
786
786
|
def _get_confirmation_requests(events: list) -> list:
|
|
787
|
-
|
|
788
|
-
for ev in events:
|
|
787
|
+
for ev in reversed(events):
|
|
789
788
|
try:
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
789
|
+
fcs = [
|
|
790
|
+
fc for fc in (ev.get_function_calls() or [])
|
|
791
|
+
if getattr(fc, "name", None) == REQUEST_CONFIRMATION_FC
|
|
792
|
+
]
|
|
793
|
+
if fcs:
|
|
794
|
+
return fcs
|
|
793
795
|
except Exception:
|
|
794
796
|
continue
|
|
795
|
-
return
|
|
797
|
+
return []
|
|
796
798
|
|
|
797
799
|
def _extract_hint_and_tool(fc) -> tuple[str, str]:
|
|
798
800
|
tool_name = "unknown_tool"
|
|
@@ -15,6 +15,26 @@ def trust_json_path() -> Path:
|
|
|
15
15
|
return _trust_file_path()
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _hosted_tenant_root() -> Path | None:
|
|
19
|
+
"""Locked workspace root when ``GEMCODE_HOSTED_TENANT_ROOT`` is set."""
|
|
20
|
+
raw = os.environ.get("GEMCODE_HOSTED_TENANT_ROOT", "").strip()
|
|
21
|
+
if not raw:
|
|
22
|
+
return None
|
|
23
|
+
return Path(raw).expanduser().resolve()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_within_root(root: Path, candidate: Path) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
candidate.resolve().relative_to(root.resolve())
|
|
29
|
+
return True
|
|
30
|
+
except ValueError:
|
|
31
|
+
return candidate.resolve() == root.resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_hosted_mode() -> bool:
|
|
35
|
+
return _hosted_tenant_root() is not None
|
|
36
|
+
|
|
37
|
+
|
|
18
38
|
def load_trusted_roots() -> set[str]:
|
|
19
39
|
"""
|
|
20
40
|
Returns a set of resolved absolute paths (as strings) that are trusted.
|
|
@@ -44,8 +64,31 @@ def save_trusted_roots(roots: set[str]) -> None:
|
|
|
44
64
|
|
|
45
65
|
|
|
46
66
|
def is_trusted_root(root: Path) -> bool:
|
|
47
|
-
r =
|
|
48
|
-
|
|
67
|
+
r = root.resolve()
|
|
68
|
+
locked = _hosted_tenant_root()
|
|
69
|
+
if locked is not None and _is_within_root(locked, r):
|
|
70
|
+
return True
|
|
71
|
+
return str(r) in load_trusted_roots()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ensure_hosted_workspace_trust(root: Path | None = None) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
In hosted multi-tenant mode, trust the tenant workspace automatically.
|
|
77
|
+
|
|
78
|
+
Interactive folder-trust prompts are not used on shared infrastructure — the
|
|
79
|
+
platform already isolates each user to their PVC. Persists trust for tooling
|
|
80
|
+
that reads ``trust.json`` directly.
|
|
81
|
+
"""
|
|
82
|
+
locked = _hosted_tenant_root()
|
|
83
|
+
if locked is None:
|
|
84
|
+
return False
|
|
85
|
+
target = (root or locked).resolve()
|
|
86
|
+
if not _is_within_root(locked, target):
|
|
87
|
+
target = locked
|
|
88
|
+
trust_root(locked, trusted=True)
|
|
89
|
+
if target != locked:
|
|
90
|
+
trust_root(target, trusted=True)
|
|
91
|
+
return True
|
|
49
92
|
|
|
50
93
|
|
|
51
94
|
def trust_root(root: Path, *, trusted: bool) -> None:
|