@build-astron-co/nimbus 0.2.0 → 0.4.0
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.
- package/bin/nimbus +26 -10
- package/bin/nimbus.cmd +41 -0
- package/bin/nimbus.mjs +70 -0
- package/completions/nimbus.bash +38 -0
- package/completions/nimbus.fish +48 -0
- package/completions/nimbus.zsh +81 -0
- package/dist/src/agent/compaction-agent.js +215 -0
- package/dist/src/agent/context-manager.js +385 -0
- package/dist/src/agent/context.js +322 -0
- package/dist/src/agent/deploy-preview.js +395 -0
- package/dist/src/agent/expand-files.js +95 -0
- package/dist/src/agent/index.js +18 -0
- package/dist/src/agent/loop.js +1535 -0
- package/dist/src/agent/modes.js +347 -0
- package/dist/src/agent/permissions.js +396 -0
- package/dist/src/agent/subagents/base.js +67 -0
- package/dist/src/agent/subagents/cost.js +45 -0
- package/dist/src/agent/subagents/explore.js +36 -0
- package/dist/src/agent/subagents/general.js +41 -0
- package/dist/src/agent/subagents/index.js +88 -0
- package/dist/src/agent/subagents/infra.js +52 -0
- package/dist/src/agent/subagents/security.js +60 -0
- package/dist/src/agent/system-prompt.js +860 -0
- package/dist/src/app.js +152 -0
- package/dist/src/audit/activity-log.js +209 -0
- package/dist/src/audit/compliance-checker.js +419 -0
- package/dist/src/audit/cost-tracker.js +231 -0
- package/dist/src/audit/index.js +10 -0
- package/dist/src/audit/security-scanner.js +490 -0
- package/dist/src/auth/guard.js +64 -0
- package/dist/src/auth/index.js +19 -0
- package/dist/src/auth/keychain.js +79 -0
- package/dist/src/auth/oauth.js +389 -0
- package/dist/src/auth/providers.js +415 -0
- package/dist/src/auth/sso.js +87 -0
- package/dist/src/auth/store.js +424 -0
- package/dist/src/auth/types.js +5 -0
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/init.js +1048 -0
- package/dist/src/cli/openapi-spec.js +346 -0
- package/dist/src/cli/run.js +505 -0
- package/dist/src/cli/serve-auth.js +56 -0
- package/dist/src/cli/serve.js +432 -0
- package/dist/src/cli/web.js +50 -0
- package/dist/src/cli.js +1574 -0
- package/dist/src/clients/core-engine-client.js +156 -0
- package/dist/src/clients/enterprise-client.js +246 -0
- package/dist/src/clients/generator-client.js +219 -0
- package/dist/src/clients/git-client.js +367 -0
- package/dist/src/clients/github-client.js +229 -0
- package/dist/src/clients/helm-client.js +299 -0
- package/dist/src/clients/index.js +18 -0
- package/dist/src/clients/k8s-client.js +270 -0
- package/dist/src/clients/llm-client.js +119 -0
- package/dist/src/clients/rest-client.js +104 -0
- package/dist/src/clients/service-discovery.js +35 -0
- package/dist/src/clients/terraform-client.js +302 -0
- package/dist/src/clients/tools-client.js +1227 -0
- package/dist/src/clients/ws-client.js +93 -0
- package/dist/src/commands/alias.js +91 -0
- package/dist/src/commands/analyze/index.js +313 -0
- package/dist/src/commands/apply/helm.js +375 -0
- package/dist/src/commands/apply/index.js +176 -0
- package/dist/src/commands/apply/k8s.js +350 -0
- package/dist/src/commands/apply/terraform.js +465 -0
- package/dist/src/commands/ask.js +137 -0
- package/dist/src/commands/audit/index.js +322 -0
- package/dist/src/commands/auth-cloud.js +345 -0
- package/dist/src/commands/auth-list.js +112 -0
- package/dist/src/commands/auth-profile.js +104 -0
- package/dist/src/commands/auth-refresh.js +161 -0
- package/dist/src/commands/auth-status.js +122 -0
- package/dist/src/commands/aws/ec2.js +402 -0
- package/dist/src/commands/aws/iam.js +304 -0
- package/dist/src/commands/aws/index.js +108 -0
- package/dist/src/commands/aws/lambda.js +317 -0
- package/dist/src/commands/aws/rds.js +345 -0
- package/dist/src/commands/aws/s3.js +346 -0
- package/dist/src/commands/aws/vpc.js +302 -0
- package/dist/src/commands/aws-discover.js +413 -0
- package/dist/src/commands/aws-terraform.js +618 -0
- package/dist/src/commands/azure/aks.js +305 -0
- package/dist/src/commands/azure/functions.js +200 -0
- package/dist/src/commands/azure/index.js +93 -0
- package/dist/src/commands/azure/storage.js +378 -0
- package/dist/src/commands/azure/vm.js +291 -0
- package/dist/src/commands/billing/index.js +224 -0
- package/dist/src/commands/chat.js +259 -0
- package/dist/src/commands/completions.js +255 -0
- package/dist/src/commands/config.js +291 -0
- package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
- package/dist/src/commands/cost/estimator.js +73 -0
- package/dist/src/commands/cost/index.js +625 -0
- package/dist/src/commands/cost/parsers/terraform.js +234 -0
- package/dist/src/commands/cost/parsers/types.js +4 -0
- package/dist/src/commands/cost/pricing/aws.js +501 -0
- package/dist/src/commands/cost/pricing/azure.js +462 -0
- package/dist/src/commands/cost/pricing/gcp.js +359 -0
- package/dist/src/commands/cost/pricing/index.js +24 -0
- package/dist/src/commands/demo.js +196 -0
- package/dist/src/commands/deploy.js +215 -0
- package/dist/src/commands/doctor.js +1291 -0
- package/dist/src/commands/drift/index.js +674 -0
- package/dist/src/commands/explain.js +235 -0
- package/dist/src/commands/export.js +120 -0
- package/dist/src/commands/feedback.js +319 -0
- package/dist/src/commands/fix.js +263 -0
- package/dist/src/commands/fs/index.js +338 -0
- package/dist/src/commands/gcp/compute.js +266 -0
- package/dist/src/commands/gcp/functions.js +221 -0
- package/dist/src/commands/gcp/gke.js +357 -0
- package/dist/src/commands/gcp/iam.js +295 -0
- package/dist/src/commands/gcp/index.js +105 -0
- package/dist/src/commands/gcp/storage.js +232 -0
- package/dist/src/commands/generate-helm.js +1026 -0
- package/dist/src/commands/generate-k8s.js +1263 -0
- package/dist/src/commands/generate-terraform.js +1058 -0
- package/dist/src/commands/gh/index.js +663 -0
- package/dist/src/commands/git/index.js +1208 -0
- package/dist/src/commands/helm/index.js +985 -0
- package/dist/src/commands/help.js +639 -0
- package/dist/src/commands/history.js +120 -0
- package/dist/src/commands/import.js +782 -0
- package/dist/src/commands/incident.js +144 -0
- package/dist/src/commands/index.js +109 -0
- package/dist/src/commands/init.js +955 -0
- package/dist/src/commands/k8s/index.js +979 -0
- package/dist/src/commands/login.js +588 -0
- package/dist/src/commands/logout.js +61 -0
- package/dist/src/commands/logs.js +160 -0
- package/dist/src/commands/onboarding.js +382 -0
- package/dist/src/commands/pipeline.js +153 -0
- package/dist/src/commands/plan/display.js +216 -0
- package/dist/src/commands/plan/index.js +525 -0
- package/dist/src/commands/plugin.js +325 -0
- package/dist/src/commands/preview.js +356 -0
- package/dist/src/commands/profile.js +297 -0
- package/dist/src/commands/questionnaire.js +1021 -0
- package/dist/src/commands/resume.js +35 -0
- package/dist/src/commands/rollback.js +259 -0
- package/dist/src/commands/rollout.js +74 -0
- package/dist/src/commands/runbook.js +307 -0
- package/dist/src/commands/schedule.js +202 -0
- package/dist/src/commands/status.js +213 -0
- package/dist/src/commands/team/index.js +309 -0
- package/dist/src/commands/team-context.js +200 -0
- package/dist/src/commands/template.js +204 -0
- package/dist/src/commands/tf/index.js +989 -0
- package/dist/src/commands/upgrade.js +515 -0
- package/dist/src/commands/usage/index.js +118 -0
- package/dist/src/commands/version.js +145 -0
- package/dist/src/commands/watch.js +127 -0
- package/dist/src/compat/index.js +2 -0
- package/dist/src/compat/runtime.js +10 -0
- package/dist/src/compat/sqlite.js +144 -0
- package/dist/src/config/index.js +6 -0
- package/dist/src/config/manager.js +469 -0
- package/dist/src/config/mode-store.js +57 -0
- package/dist/src/config/profiles.js +66 -0
- package/dist/src/config/safety-policy.js +251 -0
- package/dist/src/config/schema.js +107 -0
- package/dist/src/config/types.js +311 -0
- package/dist/src/config/workspace-state.js +38 -0
- package/dist/src/context/context-db.js +138 -0
- package/dist/src/demo/index.js +295 -0
- package/dist/src/demo/scenarios/full-journey.js +226 -0
- package/dist/src/demo/scenarios/getting-started.js +124 -0
- package/dist/src/demo/scenarios/helm-release.js +334 -0
- package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
- package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
- package/dist/src/demo/types.js +6 -0
- package/dist/src/engine/cost-estimator.js +334 -0
- package/dist/src/engine/diagram-generator.js +192 -0
- package/dist/src/engine/drift-detector.js +688 -0
- package/dist/src/engine/executor.js +832 -0
- package/dist/src/engine/index.js +39 -0
- package/dist/src/engine/orchestrator.js +436 -0
- package/dist/src/engine/planner.js +616 -0
- package/dist/src/engine/safety.js +609 -0
- package/dist/src/engine/verifier.js +664 -0
- package/dist/src/enterprise/audit.js +241 -0
- package/dist/src/enterprise/auth.js +189 -0
- package/dist/src/enterprise/billing.js +512 -0
- package/dist/src/enterprise/index.js +16 -0
- package/dist/src/enterprise/teams.js +315 -0
- package/dist/src/generator/best-practices.js +1375 -0
- package/dist/src/generator/helm.js +495 -0
- package/dist/src/generator/index.js +11 -0
- package/dist/src/generator/intent-parser.js +420 -0
- package/dist/src/generator/kubernetes.js +773 -0
- package/dist/src/generator/terraform.js +1472 -0
- package/dist/src/history/index.js +6 -0
- package/dist/src/history/manager.js +199 -0
- package/dist/src/history/types.js +6 -0
- package/dist/src/hooks/config.js +318 -0
- package/dist/src/hooks/engine.js +317 -0
- package/dist/src/hooks/index.js +2 -0
- package/dist/src/llm/auth-bridge.js +157 -0
- package/dist/src/llm/circuit-breaker.js +116 -0
- package/dist/src/llm/config-loader.js +172 -0
- package/dist/src/llm/cost-calculator.js +137 -0
- package/dist/src/llm/index.js +7 -0
- package/dist/src/llm/model-aliases.js +99 -0
- package/dist/src/llm/provider-registry.js +57 -0
- package/dist/src/llm/providers/anthropic.js +430 -0
- package/dist/src/llm/providers/bedrock.js +409 -0
- package/dist/src/llm/providers/google.js +344 -0
- package/dist/src/llm/providers/ollama.js +661 -0
- package/dist/src/llm/providers/openai-compatible.js +289 -0
- package/dist/src/llm/providers/openai.js +284 -0
- package/dist/src/llm/providers/openrouter.js +293 -0
- package/dist/src/llm/router.js +844 -0
- package/dist/src/llm/types.js +69 -0
- package/dist/src/lsp/client.js +239 -0
- package/dist/src/lsp/languages.js +95 -0
- package/dist/src/lsp/manager.js +243 -0
- package/dist/src/mcp/client.js +289 -0
- package/dist/src/mcp/index.js +5 -0
- package/dist/src/mcp/manager.js +113 -0
- package/dist/src/nimbus.js +212 -0
- package/dist/src/plugins/index.js +13 -0
- package/dist/src/plugins/loader.js +280 -0
- package/dist/src/plugins/manager.js +282 -0
- package/dist/src/plugins/types.js +23 -0
- package/dist/src/scanners/cicd-scanner.js +230 -0
- package/dist/src/scanners/cloud-scanner.js +415 -0
- package/dist/src/scanners/framework-scanner.js +430 -0
- package/dist/src/scanners/iac-scanner.js +350 -0
- package/dist/src/scanners/index.js +454 -0
- package/dist/src/scanners/language-scanner.js +258 -0
- package/dist/src/scanners/package-manager-scanner.js +252 -0
- package/dist/src/scanners/types.js +6 -0
- package/dist/src/sessions/manager.js +395 -0
- package/dist/src/sessions/types.js +4 -0
- package/dist/src/sharing/sync.js +238 -0
- package/dist/src/sharing/viewer.js +131 -0
- package/dist/src/snapshots/index.js +1 -0
- package/dist/src/snapshots/manager.js +432 -0
- package/dist/src/state/artifacts.js +94 -0
- package/dist/src/state/audit.js +73 -0
- package/dist/src/state/billing.js +126 -0
- package/dist/src/state/checkpoints.js +81 -0
- package/dist/src/state/config.js +58 -0
- package/dist/src/state/conversations.js +7 -0
- package/dist/src/state/credentials.js +96 -0
- package/dist/src/state/db.js +53 -0
- package/dist/src/state/index.js +23 -0
- package/dist/src/state/messages.js +76 -0
- package/dist/src/state/projects.js +92 -0
- package/dist/src/state/schema.js +233 -0
- package/dist/src/state/sessions.js +79 -0
- package/dist/src/state/teams.js +131 -0
- package/dist/src/telemetry.js +91 -0
- package/dist/src/tools/aws-ops.js +747 -0
- package/dist/src/tools/azure-ops.js +491 -0
- package/dist/src/tools/file-ops.js +451 -0
- package/dist/src/tools/gcp-ops.js +559 -0
- package/dist/src/tools/git-ops.js +557 -0
- package/dist/src/tools/github-ops.js +460 -0
- package/dist/src/tools/helm-ops.js +634 -0
- package/dist/src/tools/index.js +16 -0
- package/dist/src/tools/k8s-ops.js +579 -0
- package/dist/src/tools/schemas/converter.js +129 -0
- package/dist/src/tools/schemas/devops.js +3319 -0
- package/dist/src/tools/schemas/index.js +19 -0
- package/dist/src/tools/schemas/standard.js +966 -0
- package/dist/src/tools/schemas/types.js +409 -0
- package/dist/src/tools/spawn-exec.js +109 -0
- package/dist/src/tools/terraform-ops.js +627 -0
- package/dist/src/types/config.js +1 -0
- package/dist/src/types/drift.js +4 -0
- package/dist/src/types/enterprise.js +5 -0
- package/dist/src/types/index.js +14 -0
- package/dist/src/types/plan.js +1 -0
- package/dist/src/types/request.js +1 -0
- package/dist/src/types/response.js +1 -0
- package/dist/src/types/service.js +1 -0
- package/dist/src/ui/App.js +1672 -0
- package/dist/src/ui/DeployPreview.js +60 -0
- package/dist/src/ui/FileDiffModal.js +108 -0
- package/dist/src/ui/Header.js +46 -0
- package/dist/src/ui/HelpModal.js +9 -0
- package/dist/src/ui/InputBox.js +408 -0
- package/dist/src/ui/MessageList.js +795 -0
- package/dist/src/ui/PermissionPrompt.js +72 -0
- package/dist/src/ui/StatusBar.js +109 -0
- package/dist/src/ui/TerminalPane.js +31 -0
- package/dist/src/ui/ToolCallDisplay.js +303 -0
- package/dist/src/ui/TreePane.js +83 -0
- package/dist/src/ui/chat-ui.js +721 -0
- package/dist/src/ui/index.js +11 -0
- package/dist/src/ui/ink/index.js +1325 -0
- package/dist/src/ui/streaming.js +137 -0
- package/dist/src/ui/theme.js +78 -0
- package/dist/src/ui/types.js +7 -0
- package/dist/src/utils/analytics.js +61 -0
- package/dist/src/utils/cost-warning.js +25 -0
- package/dist/src/utils/env.js +42 -0
- package/dist/src/utils/errors.js +54 -0
- package/dist/src/utils/event-bus.js +22 -0
- package/dist/src/utils/index.js +16 -0
- package/dist/src/utils/logger.js +150 -0
- package/dist/src/utils/rate-limiter.js +90 -0
- package/dist/src/utils/service-auth.js +36 -0
- package/dist/src/utils/validation.js +39 -0
- package/dist/src/version.js +3 -0
- package/dist/src/watcher/index.js +192 -0
- package/dist/src/wizard/approval.js +275 -0
- package/dist/src/wizard/index.js +13 -0
- package/dist/src/wizard/prompts.js +273 -0
- package/dist/src/wizard/types.js +4 -0
- package/dist/src/wizard/ui.js +453 -0
- package/dist/src/wizard/wizard.js +227 -0
- package/package.json +31 -23
- package/src/__tests__/alias.test.ts +133 -0
- package/src/__tests__/app.test.ts +1 -1
- package/src/__tests__/audit.test.ts +1 -1
- package/src/__tests__/circuit-breaker.test.ts +1 -1
- package/src/__tests__/cli-run.test.ts +237 -1
- package/src/__tests__/compat-sqlite.test.ts +68 -0
- package/src/__tests__/context-manager.test.ts +131 -1
- package/src/__tests__/context.test.ts +1 -1
- package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
- package/src/__tests__/doctor.test.ts +48 -0
- package/src/__tests__/enterprise.test.ts +1 -1
- package/src/__tests__/export.test.ts +236 -0
- package/src/__tests__/gap-11-18-20.test.ts +958 -0
- package/src/__tests__/generator.test.ts +1 -1
- package/src/__tests__/helm-streaming.test.ts +127 -0
- package/src/__tests__/hooks.test.ts +1 -1
- package/src/__tests__/incident.test.ts +179 -0
- package/src/__tests__/init.test.ts +55 -4
- package/src/__tests__/intent-parser.test.ts +1 -1
- package/src/__tests__/llm-router.test.ts +1 -1
- package/src/__tests__/logs.test.ts +107 -0
- package/src/__tests__/loop-errors.test.ts +244 -0
- package/src/__tests__/lsp.test.ts +1 -1
- package/src/__tests__/modes.test.ts +1 -1
- package/src/__tests__/perf-optimizations.test.ts +847 -0
- package/src/__tests__/permissions.test.ts +1 -1
- package/src/__tests__/pipeline.test.ts +50 -0
- package/src/__tests__/polish-phase3.test.ts +340 -0
- package/src/__tests__/profile.test.ts +237 -0
- package/src/__tests__/rollback.test.ts +83 -0
- package/src/__tests__/runbook.test.ts +219 -0
- package/src/__tests__/schedule.test.ts +206 -0
- package/src/__tests__/serve.test.ts +1 -1
- package/src/__tests__/sessions.test.ts +96 -1
- package/src/__tests__/sharing.test.ts +53 -1
- package/src/__tests__/snapshots.test.ts +1 -1
- package/src/__tests__/standalone-migration.test.ts +199 -0
- package/src/__tests__/state-db.test.ts +1 -1
- package/src/__tests__/status.test.ts +158 -0
- package/src/__tests__/stream-with-tools.test.ts +71 -25
- package/src/__tests__/subagents.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +82 -3
- package/src/__tests__/terminal-gap-v2.test.ts +395 -0
- package/src/__tests__/terminal-parity.test.ts +393 -0
- package/src/__tests__/tf-apply.test.ts +187 -0
- package/src/__tests__/tool-converter.test.ts +1 -1
- package/src/__tests__/tool-schemas.test.ts +209 -4
- package/src/__tests__/tools.test.ts +4 -3
- package/src/__tests__/version-json.test.ts +184 -0
- package/src/__tests__/version.test.ts +1 -1
- package/src/__tests__/watch.test.ts +129 -0
- package/src/agent/compaction-agent.ts +40 -1
- package/src/agent/context-manager.ts +67 -3
- package/src/agent/deploy-preview.ts +62 -1
- package/src/agent/expand-files.ts +108 -0
- package/src/agent/loop.ts +1312 -31
- package/src/agent/permissions.ts +51 -4
- package/src/agent/system-prompt.ts +573 -19
- package/src/app.ts +58 -0
- package/src/audit/security-scanner.ts +45 -0
- package/src/auth/keychain.ts +82 -0
- package/src/auth/oauth.ts +15 -5
- package/src/cli/init.ts +378 -5
- package/src/cli/run.ts +407 -16
- package/src/cli/serve.ts +78 -1
- package/src/cli/web.ts +10 -6
- package/src/cli.ts +312 -1
- package/src/clients/service-discovery.ts +30 -25
- package/src/commands/alias.ts +100 -0
- package/src/commands/audit/index.ts +121 -2
- package/src/commands/auth-cloud.ts +113 -0
- package/src/commands/auth-refresh.ts +187 -0
- package/src/commands/aws-discover.ts +144 -251
- package/src/commands/aws-terraform.ts +68 -118
- package/src/commands/chat.ts +9 -3
- package/src/commands/completions.ts +268 -0
- package/src/commands/config.ts +26 -0
- package/src/commands/cost/index.ts +218 -2
- package/src/commands/deploy.ts +260 -0
- package/src/commands/doctor.ts +744 -152
- package/src/commands/drift/index.ts +371 -23
- package/src/commands/export.ts +146 -0
- package/src/commands/generate-k8s.ts +9 -61
- package/src/commands/generate-terraform.ts +191 -449
- package/src/commands/help.ts +212 -36
- package/src/commands/history.ts +8 -1
- package/src/commands/incident.ts +166 -0
- package/src/commands/init.ts +5 -0
- package/src/commands/login.ts +86 -1
- package/src/commands/logs.ts +167 -0
- package/src/commands/onboarding.ts +211 -34
- package/src/commands/pipeline.ts +186 -0
- package/src/commands/plugin.ts +398 -0
- package/src/commands/profile.ts +342 -0
- package/src/commands/questionnaire.ts +0 -98
- package/src/commands/resume.ts +26 -34
- package/src/commands/rollback.ts +315 -0
- package/src/commands/rollout.ts +88 -0
- package/src/commands/runbook.ts +346 -0
- package/src/commands/schedule.ts +236 -0
- package/src/commands/status.ts +252 -0
- package/src/commands/team-context.ts +220 -0
- package/src/commands/template.ts +58 -57
- package/src/commands/tf/index.ts +70 -11
- package/src/commands/upgrade.ts +57 -0
- package/src/commands/version.ts +54 -50
- package/src/commands/watch.ts +153 -0
- package/src/compat/runtime.ts +1 -1
- package/src/compat/sqlite.ts +75 -5
- package/src/config/mode-store.ts +62 -0
- package/src/config/profiles.ts +84 -0
- package/src/config/types.ts +83 -1
- package/src/config/workspace-state.ts +53 -0
- package/src/engine/cost-estimator.ts +52 -10
- package/src/engine/executor.ts +33 -2
- package/src/engine/planner.ts +68 -1
- package/src/generator/terraform.ts +8 -0
- package/src/history/manager.ts +2 -74
- package/src/hooks/engine.ts +5 -4
- package/src/llm/cost-calculator.ts +2 -2
- package/src/llm/providers/anthropic.ts +50 -21
- package/src/llm/router.ts +76 -7
- package/src/lsp/languages.ts +3 -0
- package/src/lsp/manager.ts +21 -5
- package/src/nimbus.ts +37 -18
- package/src/sessions/manager.ts +108 -1
- package/src/sharing/sync.ts +4 -0
- package/src/sharing/viewer.ts +66 -0
- package/src/tools/file-ops.ts +22 -0
- package/src/tools/schemas/devops.ts +3007 -117
- package/src/tools/schemas/standard.ts +5 -1
- package/src/tools/schemas/types.ts +31 -1
- package/src/tools/spawn-exec.ts +148 -0
- package/src/ui/App.tsx +1183 -66
- package/src/ui/DeployPreview.tsx +62 -57
- package/src/ui/FileDiffModal.tsx +162 -0
- package/src/ui/Header.tsx +87 -24
- package/src/ui/HelpModal.tsx +57 -0
- package/src/ui/InputBox.tsx +163 -10
- package/src/ui/MessageList.tsx +487 -40
- package/src/ui/PermissionPrompt.tsx +17 -5
- package/src/ui/StatusBar.tsx +122 -3
- package/src/ui/TerminalPane.tsx +84 -0
- package/src/ui/ToolCallDisplay.tsx +252 -18
- package/src/ui/TreePane.tsx +132 -0
- package/src/ui/chat-ui.ts +41 -44
- package/src/ui/ink/index.ts +771 -38
- package/src/ui/streaming.ts +1 -1
- package/src/ui/theme.ts +104 -0
- package/src/ui/types.ts +18 -0
- package/src/version.ts +1 -1
- package/src/watcher/index.ts +66 -15
- package/src/wizard/types.ts +1 -0
- package/src/wizard/ui.ts +1 -1
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,1535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Agentic Loop
|
|
3
|
+
*
|
|
4
|
+
* Implements the autonomous agent loop:
|
|
5
|
+
* 1. Build context (system prompt + history + tools)
|
|
6
|
+
* 2. Send to LLM with tools enabled
|
|
7
|
+
* 3. Stream text response
|
|
8
|
+
* 4. If tool_use: check permissions → execute → collect results
|
|
9
|
+
* 5. Append messages → loop back to LLM
|
|
10
|
+
* 6. Exit when LLM returns end_turn (no more tool calls)
|
|
11
|
+
*
|
|
12
|
+
* This is the heart of the Nimbus agent. Every user message enters
|
|
13
|
+
* {@link runAgentLoop}, which orchestrates a multi-turn conversation with
|
|
14
|
+
* the LLM, executing tools on its behalf until it signals completion by
|
|
15
|
+
* returning a response with no further tool calls.
|
|
16
|
+
*
|
|
17
|
+
* @module agent/loop
|
|
18
|
+
*/
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { toOpenAITool, } from '../tools/schemas/types';
|
|
21
|
+
import { buildSystemPrompt } from './system-prompt';
|
|
22
|
+
import { runCompaction } from './compaction-agent';
|
|
23
|
+
import { SnapshotManager } from '../snapshots/manager';
|
|
24
|
+
import { calculateCost } from '../llm/cost-calculator';
|
|
25
|
+
import { runPreToolHooks, runPostToolHooks, } from '../hooks/engine';
|
|
26
|
+
import { maskSecrets } from '../audit/security-scanner';
|
|
27
|
+
import { classifyTaskComplexity, routeModel } from '../llm/router';
|
|
28
|
+
import { mkdirSync as _cpMkdirSync, writeFileSync as _cpWriteFileSync } from 'node:fs';
|
|
29
|
+
import { homedir as _cpHomedir } from 'node:os';
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// C2: Infra state checkpoint helper
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* Write a checkpoint JSON file to ~/.nimbus/infra-checkpoints/<timestamp>.json
|
|
35
|
+
* before a mutating terraform or helm operation. Non-blocking — errors are swallowed.
|
|
36
|
+
*/
|
|
37
|
+
function writeInfraCheckpoint(tool, action, input) {
|
|
38
|
+
try {
|
|
39
|
+
const checkpointsDir = join(_cpHomedir(), '.nimbus', 'infra-checkpoints');
|
|
40
|
+
_cpMkdirSync(checkpointsDir, { recursive: true });
|
|
41
|
+
// Sanitize: remove any field that looks like a secret
|
|
42
|
+
const sanitized = {};
|
|
43
|
+
for (const [k, v] of Object.entries(input)) {
|
|
44
|
+
const lower = k.toLowerCase();
|
|
45
|
+
if (lower.includes('secret') || lower.includes('password') || lower.includes('token') || lower.includes('key')) {
|
|
46
|
+
sanitized[k] = '[redacted]';
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
sanitized[k] = v;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const timestamp = new Date().toISOString();
|
|
53
|
+
const checkpoint = {
|
|
54
|
+
timestamp,
|
|
55
|
+
tool,
|
|
56
|
+
action,
|
|
57
|
+
input: sanitized,
|
|
58
|
+
cwd: process.cwd(),
|
|
59
|
+
workdir: input.workdir ?? undefined,
|
|
60
|
+
};
|
|
61
|
+
const fileName = timestamp.replace(/[:.]/g, '-') + '.json';
|
|
62
|
+
_cpWriteFileSync(join(checkpointsDir, fileName), JSON.stringify(checkpoint, null, 2), 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
catch { /* non-critical */ }
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Helpers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Module-level compiled regex constants for classifyDevOpsError (PERF-1d).
|
|
71
|
+
// Hoisted here so they compile once at module load rather than per-call.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
const _RE_CREDENTIAL_EXPIRY_AWS = /ExpiredTokenException|TokenExpiredException|token.*has.*expired/i;
|
|
74
|
+
const _RE_CREDENTIAL_EXPIRY_GCP = /credentials.*expired|Application Default Credentials.*expired|re-authenticate/i;
|
|
75
|
+
const _RE_CREDENTIAL_EXPIRY_AZURE = /AADSTS70008|InteractionRequired|credential.*expired/i;
|
|
76
|
+
const _RE_CMD_NOT_FOUND = /command not found|not found|no such file or directory/i;
|
|
77
|
+
/**
|
|
78
|
+
* Classify a DevOps tool error and return an actionable hint for the LLM.
|
|
79
|
+
* Returns null for unrecognized errors so we don't pollute the context.
|
|
80
|
+
*/
|
|
81
|
+
function classifyDevOpsError(toolName, errorOutput, nimbusInstructions) {
|
|
82
|
+
const e = errorOutput.toLowerCase();
|
|
83
|
+
// GAP-13: Credential expiry patterns — must come first for fast matching
|
|
84
|
+
const CREDENTIAL_EXPIRY = [
|
|
85
|
+
{ re: _RE_CREDENTIAL_EXPIRY_AWS, provider: 'aws' },
|
|
86
|
+
{ re: _RE_CREDENTIAL_EXPIRY_GCP, provider: 'gcp' },
|
|
87
|
+
{ re: _RE_CREDENTIAL_EXPIRY_AZURE, provider: 'azure' },
|
|
88
|
+
];
|
|
89
|
+
for (const { re, provider } of CREDENTIAL_EXPIRY) {
|
|
90
|
+
if (re.test(errorOutput)) {
|
|
91
|
+
return `Your ${provider.toUpperCase()} credentials have expired.\n\nRun: \`nimbus auth-refresh --provider ${provider}\` to refresh them.`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// G3: "command not found" — provide installation hints for DevOps CLIs
|
|
95
|
+
const INSTALL_HINTS = {
|
|
96
|
+
terraform: 'brew install terraform OR https://developer.hashicorp.com/terraform/install',
|
|
97
|
+
kubectl: 'brew install kubectl OR https://kubernetes.io/docs/tasks/tools/',
|
|
98
|
+
helm: 'brew install helm OR https://helm.sh/docs/intro/install/',
|
|
99
|
+
docker: 'brew install --cask docker OR https://docs.docker.com/get-docker/',
|
|
100
|
+
aws: 'brew install awscli OR pip install awscli',
|
|
101
|
+
gcloud: 'brew install --cask google-cloud-sdk',
|
|
102
|
+
az: 'brew install azure-cli',
|
|
103
|
+
};
|
|
104
|
+
if (_RE_CMD_NOT_FOUND.test(errorOutput)) {
|
|
105
|
+
for (const [cmd, hint] of Object.entries(INSTALL_HINTS)) {
|
|
106
|
+
if (toolName.includes(cmd) || e.includes(`'${cmd}'`) || e.includes(`"${cmd}"`)) {
|
|
107
|
+
return `\`${cmd}\` is not installed.\n\nInstall: ${hint}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Terraform errors
|
|
112
|
+
if (toolName === 'terraform' || e.includes('terraform')) {
|
|
113
|
+
if (e.includes('no such file or directory') && e.includes('.terraform')) {
|
|
114
|
+
return 'HINT: Run `terraform init` first — the .terraform directory is missing.';
|
|
115
|
+
}
|
|
116
|
+
if (e.includes('provider') && e.includes('required') && e.includes('terraform')) {
|
|
117
|
+
return 'HINT: Run `terraform init -upgrade` to download or upgrade required providers.';
|
|
118
|
+
}
|
|
119
|
+
if (e.includes('no valid credential') || e.includes('no credentials')) {
|
|
120
|
+
return 'HINT: AWS/cloud credentials are missing. Check `aws configure` or environment variables.';
|
|
121
|
+
}
|
|
122
|
+
if (e.includes('state lock') || e.includes('lock file')) {
|
|
123
|
+
return 'HINT: Terraform state is locked. If no other operation is running, use `terraform force-unlock <lock-id>`.';
|
|
124
|
+
}
|
|
125
|
+
if (e.includes('module not installed') || e.includes('module source')) {
|
|
126
|
+
return 'HINT: Run `terraform init` to install required modules.';
|
|
127
|
+
}
|
|
128
|
+
if (e.includes('quota') || e.includes('limit exceeded') || e.includes('vcpu')) {
|
|
129
|
+
return 'HINT: Cloud resource quota exceeded. Request a limit increase in the cloud console.';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Kubernetes errors
|
|
133
|
+
if (toolName === 'kubectl' || toolName === 'kubectl_context') {
|
|
134
|
+
if (e.includes('connection refused') || e.includes('unable to connect')) {
|
|
135
|
+
return 'HINT: Cannot reach the Kubernetes API server. Check `kubectl config current-context` and ensure the cluster is accessible.';
|
|
136
|
+
}
|
|
137
|
+
if (e.includes('unauthorized') || e.includes('forbidden')) {
|
|
138
|
+
return 'HINT: Insufficient permissions. Check your kubeconfig credentials or RBAC roles.';
|
|
139
|
+
}
|
|
140
|
+
if (e.includes('not found') && e.includes('namespace')) {
|
|
141
|
+
return 'HINT: The namespace does not exist. Create it with `kubectl create namespace <name>` first.';
|
|
142
|
+
}
|
|
143
|
+
if (e.includes('image') && (e.includes('not found') || e.includes('pull'))) {
|
|
144
|
+
return 'HINT: Container image pull failed. Verify the image name, tag, and registry credentials (imagePullSecret).';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Helm errors
|
|
148
|
+
if (toolName === 'helm' || toolName === 'helm_values') {
|
|
149
|
+
if (e.includes('chart not found') || e.includes('no such chart')) {
|
|
150
|
+
return 'HINT: Chart not found. Run `helm repo update` and verify the chart name.';
|
|
151
|
+
}
|
|
152
|
+
if (e.includes('release not found')) {
|
|
153
|
+
return 'HINT: Helm release not found. Use `helm list -A` to see all releases across namespaces.';
|
|
154
|
+
}
|
|
155
|
+
if (e.includes('unable to build kubernetes objects') || e.includes('manifest')) {
|
|
156
|
+
return 'HINT: Helm template rendering failed. Run `helm template <release> <chart>` to debug the manifests.';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Cloud CLI errors
|
|
160
|
+
if (toolName === 'cloud_discover' || toolName === 'cloud_action') {
|
|
161
|
+
if (e.includes('not authorized') || e.includes('access denied') || e.includes('unauthorized')) {
|
|
162
|
+
return 'HINT: Cloud credentials lack required permissions. Check IAM policies/roles for the operation.';
|
|
163
|
+
}
|
|
164
|
+
if (e.includes('region') && e.includes('not found')) {
|
|
165
|
+
return 'HINT: Invalid region. Check `aws configure get region` or pass --region explicitly.';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Docker errors
|
|
169
|
+
if (toolName === 'docker') {
|
|
170
|
+
if (e.includes('cannot connect to the docker daemon') || e.includes('docker daemon') || e.includes('docker.sock')) {
|
|
171
|
+
return 'HINT: Docker daemon is not running. Start it with `colima start` (macOS) or `sudo systemctl start docker` (Linux).';
|
|
172
|
+
}
|
|
173
|
+
if (e.includes('manifest unknown') || e.includes('manifest not found') || e.includes('not found')) {
|
|
174
|
+
return 'HINT: Image not found. Verify the image name and tag. Check registry credentials with `docker login`.';
|
|
175
|
+
}
|
|
176
|
+
if (e.includes('no space left on device') || e.includes('no space left')) {
|
|
177
|
+
return 'HINT: Docker disk space exhausted. Run `docker system prune -f` to reclaim space.';
|
|
178
|
+
}
|
|
179
|
+
if (e.includes('permission denied') && e.includes('docker')) {
|
|
180
|
+
return 'HINT: Docker permission denied. Add your user to the docker group: `sudo usermod -aG docker $USER`.';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Secrets errors
|
|
184
|
+
if (toolName === 'secrets') {
|
|
185
|
+
if (e.includes('permission denied') || e.includes('403') || e.includes('accessdenied')) {
|
|
186
|
+
return 'HINT: Secrets access denied. Check Vault policy with `vault policy read <policy>` or IAM role permissions.';
|
|
187
|
+
}
|
|
188
|
+
if (e.includes('secret not found') || e.includes('no such secret') || e.includes('resourcenotfoundexception')) {
|
|
189
|
+
return 'HINT: Secret not found. Verify the secret path/name and namespace. Use `vault kv list <mount>` to browse.';
|
|
190
|
+
}
|
|
191
|
+
if (e.includes('invalid token') || e.includes('token expired')) {
|
|
192
|
+
return 'HINT: Vault/cloud token expired. Run `vault login` or refresh cloud credentials with `nimbus auth-refresh`.';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// CI/CD errors
|
|
196
|
+
if (toolName === 'cicd') {
|
|
197
|
+
if (e.includes('workflow not found') || e.includes('could not find workflow')) {
|
|
198
|
+
return 'HINT: Workflow not found. Check the workflow filename in .github/workflows/ and the branch name.';
|
|
199
|
+
}
|
|
200
|
+
if (e.includes('rate limit') || e.includes('429') || e.includes('too many requests')) {
|
|
201
|
+
return 'HINT: API rate limited. Wait 60 seconds and retry. Check rate limit headers for reset time.';
|
|
202
|
+
}
|
|
203
|
+
if (e.includes('unauthorized') || e.includes('401') || e.includes('bad credentials')) {
|
|
204
|
+
return 'HINT: CI/CD authentication failed. Check GITHUB_TOKEN, GITLAB_TOKEN, or CIRCLECI_TOKEN environment variables.';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// GitOps errors
|
|
208
|
+
if (toolName === 'gitops') {
|
|
209
|
+
if (e.includes('not found') || e.includes('not logged in') || e.includes('unauthenticated')) {
|
|
210
|
+
return 'HINT: ArgoCD/Flux not accessible. Check ARGOCD_SERVER and ARGOCD_TOKEN env vars, or run `argocd login`.';
|
|
211
|
+
}
|
|
212
|
+
if (e.includes('comparisonerror') || e.includes('sync error')) {
|
|
213
|
+
return 'HINT: GitOps sync error. Validate manifests: `kubectl apply --dry-run=client -f <manifest>` to find issues.';
|
|
214
|
+
}
|
|
215
|
+
if (e.includes('health') && e.includes('degraded')) {
|
|
216
|
+
return 'HINT: Application is degraded. Check pod logs with `kubectl logs -n <ns>` and events with `kubectl get events -n <ns>`.';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Monitoring errors
|
|
220
|
+
if (toolName === 'monitor') {
|
|
221
|
+
if (e.includes('connection refused') || e.includes('could not connect')) {
|
|
222
|
+
return 'HINT: Cannot connect to monitoring endpoint. Check PROMETHEUS_URL, GRAFANA_URL, or cloud region configuration.';
|
|
223
|
+
}
|
|
224
|
+
if (e.includes('unauthorized') || e.includes('403')) {
|
|
225
|
+
return 'HINT: Monitoring authentication failed. Check DD_API_KEY, GRAFANA_TOKEN, or NEW_RELIC_API_KEY environment variables.';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// L3: Parse NIMBUS.md custom error hints section
|
|
229
|
+
if (nimbusInstructions) {
|
|
230
|
+
const hintsMatch = nimbusInstructions.match(/##\s*Custom Error Hints\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
|
|
231
|
+
if (hintsMatch) {
|
|
232
|
+
const hintsSection = hintsMatch[1];
|
|
233
|
+
const hintLines = hintsSection.split('\n').filter(l => l.trim().startsWith('-'));
|
|
234
|
+
for (const line of hintLines) {
|
|
235
|
+
// Format: "- pattern: hint message"
|
|
236
|
+
const colonIdx = line.indexOf(':');
|
|
237
|
+
if (colonIdx > 0) {
|
|
238
|
+
const pattern = line.slice(1, colonIdx).trim();
|
|
239
|
+
const hint = line.slice(colonIdx + 1).trim();
|
|
240
|
+
if (pattern && hint && errorOutput.toLowerCase().includes(pattern.toLowerCase())) {
|
|
241
|
+
return `HINT: ${hint}`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
/** DevOps tool names that get self-diagnosis hints on unrecognized errors. */
|
|
250
|
+
const DEVOPS_TOOL_NAMES = new Set([
|
|
251
|
+
'terraform', 'kubectl', 'kubectl_context', 'helm', 'helm_values',
|
|
252
|
+
'bash', 'cloud_discover', 'drift_detect', 'deploy_preview',
|
|
253
|
+
'docker', 'secrets', 'cicd', 'monitor', 'gitops', 'cloud_action',
|
|
254
|
+
'logs', 'certs', 'mesh', 'cfn', 'k8s_rbac',
|
|
255
|
+
]);
|
|
256
|
+
/**
|
|
257
|
+
* Format a Zod (or generic) tool-input validation error into a human-readable
|
|
258
|
+
* message that tells the LLM exactly which fields are wrong and how to fix them.
|
|
259
|
+
*/
|
|
260
|
+
function formatToolInputError(toolName, err) {
|
|
261
|
+
if (err && typeof err === 'object' && 'issues' in err) {
|
|
262
|
+
// ZodError
|
|
263
|
+
const issues = err.issues;
|
|
264
|
+
const details = issues
|
|
265
|
+
.map(i => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
266
|
+
.join('\n');
|
|
267
|
+
return `Tool "${toolName}" received invalid input:\n${details}\n\nPlease correct the arguments and retry.`;
|
|
268
|
+
}
|
|
269
|
+
return `Tool "${toolName}" failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
270
|
+
}
|
|
271
|
+
/** Determine whether a streaming error is transient and worth retrying. */
|
|
272
|
+
function isRetryableStreamError(err) {
|
|
273
|
+
if (err && typeof err === 'object') {
|
|
274
|
+
const e = err;
|
|
275
|
+
const status = (typeof e.status === 'number' ? e.status : undefined) ??
|
|
276
|
+
(typeof e.statusCode === 'number' ? e.statusCode : undefined);
|
|
277
|
+
if (status === 429 || (status !== undefined && status >= 500 && status < 600))
|
|
278
|
+
return true;
|
|
279
|
+
const msg = typeof e.message === 'string' ? e.message : '';
|
|
280
|
+
if (/rate.?limit|429|too many requests|overloaded|503/i.test(msg))
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// G3: Runaway protection helpers
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
/** Patterns that indicate a destructive operation in tool arguments. */
|
|
289
|
+
const DESTRUCTIVE_PATTERNS = /\b(apply|destroy|delete|terminate|stop|remove|drop|truncate|purge)\b/i;
|
|
290
|
+
/** Tool names whose destructive operations should be counted at the session level. */
|
|
291
|
+
const DESTRUCTIVE_TOOL_NAMES = new Set([
|
|
292
|
+
'terraform', 'kubectl', 'docker', 'aws', 'gcloud', 'az', 'cloud_action', 'cfn',
|
|
293
|
+
]);
|
|
294
|
+
/**
|
|
295
|
+
* Returns true if the tool call looks like a destructive infrastructure operation.
|
|
296
|
+
* Used to enforce the session-level destructive ops counter (G3).
|
|
297
|
+
*/
|
|
298
|
+
function isDestructiveOp(toolName, inputStr) {
|
|
299
|
+
return DESTRUCTIVE_TOOL_NAMES.has(toolName) && DESTRUCTIVE_PATTERNS.test(inputStr);
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Constants
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
/** Default model when none is specified. */
|
|
305
|
+
const DEFAULT_MODEL = 'anthropic/claude-sonnet-4-20250514';
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// H5: Cost delta hint after terraform apply / helm upgrade
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
/**
|
|
310
|
+
* Extract a lightweight cost hint from tool output for display after
|
|
311
|
+
* infrastructure operations (terraform apply, helm install/upgrade).
|
|
312
|
+
*/
|
|
313
|
+
function extractCostHintFromToolOutput(toolName, input, output) {
|
|
314
|
+
// terraform apply: parse "Apply complete! Resources: N added, M changed, K destroyed."
|
|
315
|
+
if (toolName === 'terraform' && String(input.action) === 'apply') {
|
|
316
|
+
const m = output.match(/Resources:\s*(\d+) added,\s*(\d+) changed,\s*(\d+) destroyed/);
|
|
317
|
+
if (m) {
|
|
318
|
+
const added = Number(m[1]);
|
|
319
|
+
const changed = Number(m[2]);
|
|
320
|
+
const destroyed = Number(m[3]);
|
|
321
|
+
const parts = [];
|
|
322
|
+
if (added > 0)
|
|
323
|
+
parts.push(`+${added} resources created`);
|
|
324
|
+
if (changed > 0)
|
|
325
|
+
parts.push(`${changed} updated`);
|
|
326
|
+
if (destroyed > 0)
|
|
327
|
+
parts.push(`${destroyed} destroyed`);
|
|
328
|
+
return parts.length > 0
|
|
329
|
+
? `${parts.join(', ')} — run "nimbus cost" for monthly cost estimate`
|
|
330
|
+
: null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// helm install/upgrade
|
|
334
|
+
if (toolName === 'helm' && ['install', 'upgrade'].includes(String(input.action))) {
|
|
335
|
+
const releaseName = String(input.releaseName ?? input.release ?? '');
|
|
336
|
+
if (!output.includes('Error') && !output.includes('FAILED')) {
|
|
337
|
+
return `Helm release "${releaseName}" deployed — run "nimbus cost" for estimated cost impact`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// M4: Session-scoped error tracking for NIMBUS.md persistence
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
const sessionErrorCounts = new Map();
|
|
346
|
+
function trackAndPersistError(toolName, errorHint, cwd) {
|
|
347
|
+
const key = `${toolName}:${errorHint.slice(0, 60)}`;
|
|
348
|
+
const count = (sessionErrorCounts.get(key) ?? 0) + 1;
|
|
349
|
+
sessionErrorCounts.set(key, count);
|
|
350
|
+
if (count === 3) {
|
|
351
|
+
try {
|
|
352
|
+
const { existsSync, readFileSync, writeFileSync, appendFileSync } = require('node:fs');
|
|
353
|
+
const { join } = require('node:path');
|
|
354
|
+
const nimbusPath = join(cwd, 'NIMBUS.md');
|
|
355
|
+
if (!existsSync(nimbusPath))
|
|
356
|
+
return;
|
|
357
|
+
const existing = readFileSync(nimbusPath, 'utf-8');
|
|
358
|
+
if (existing.includes(errorHint.slice(0, 40)))
|
|
359
|
+
return; // already recorded
|
|
360
|
+
const entry = `- ${toolName}: ${errorHint}\n`;
|
|
361
|
+
if (existing.includes('## Observed Issues')) {
|
|
362
|
+
writeFileSync(nimbusPath, existing.replace('## Observed Issues\n', `## Observed Issues\n${entry}`));
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
appendFileSync(nimbusPath, `\n## Observed Issues\n${entry}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch { /* non-critical */ }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// M6: Destructive action guard — force confirmation before terraform destroy / kubectl delete
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
function isDestructiveAction(toolName, input) {
|
|
375
|
+
const action = String(input.action ?? input.command ?? '');
|
|
376
|
+
if (toolName === 'terraform' && action === 'destroy') {
|
|
377
|
+
return 'terraform destroy will PERMANENTLY DELETE all managed infrastructure. Explicitly confirm with the user before proceeding.';
|
|
378
|
+
}
|
|
379
|
+
if (toolName === 'kubectl' && action === 'delete') {
|
|
380
|
+
const resource = String(input.resource ?? '');
|
|
381
|
+
return `kubectl delete ${resource} is IRREVERSIBLE. Explicitly confirm with the user before proceeding.`;
|
|
382
|
+
}
|
|
383
|
+
if (toolName === 'helm' && action === 'uninstall') {
|
|
384
|
+
return 'helm uninstall will remove the release and its resources. Explicitly confirm with the user before proceeding.';
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const PLAN_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
389
|
+
const terraformPlanCache = new Map();
|
|
390
|
+
/** Store a terraform plan output for a workdir. */
|
|
391
|
+
function cacheTerraformPlan(workdir, output) {
|
|
392
|
+
terraformPlanCache.set(workdir, { output, workdir, timestamp: Date.now() });
|
|
393
|
+
}
|
|
394
|
+
/** Retrieve a cached terraform plan for a workdir, or null if expired/missing. */
|
|
395
|
+
function getCachedTerraformPlan(workdir) {
|
|
396
|
+
const entry = terraformPlanCache.get(workdir);
|
|
397
|
+
if (!entry)
|
|
398
|
+
return null;
|
|
399
|
+
if (Date.now() - entry.timestamp > PLAN_CACHE_TTL_MS) {
|
|
400
|
+
terraformPlanCache.delete(workdir);
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
return entry.output;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Background interval that evicts expired terraform plan cache entries every 60s.
|
|
407
|
+
* `.unref()` ensures this does not prevent the process from exiting.
|
|
408
|
+
* Exported for test teardown.
|
|
409
|
+
*/
|
|
410
|
+
export const _planCacheCleanupInterval = setInterval(() => {
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
for (const [key, entry] of terraformPlanCache) {
|
|
413
|
+
if (now - entry.timestamp > PLAN_CACHE_TTL_MS) {
|
|
414
|
+
terraformPlanCache.delete(key);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}, 60_000).unref();
|
|
418
|
+
/** Default max output tokens per LLM call. */
|
|
419
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
420
|
+
/** Default maximum number of agent turns. */
|
|
421
|
+
const DEFAULT_MAX_TURNS = 50;
|
|
422
|
+
/** Maximum characters of tool output to include in conversation history.
|
|
423
|
+
* Anything beyond this is truncated to prevent context window overflow. */
|
|
424
|
+
const MAX_TOOL_OUTPUT_CHARS = 100_000;
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Main Entry Point
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
/**
|
|
429
|
+
* Run the agentic loop.
|
|
430
|
+
*
|
|
431
|
+
* Takes a user message and existing conversation history, then runs
|
|
432
|
+
* the LLM in a loop until it stops requesting tool calls.
|
|
433
|
+
*
|
|
434
|
+
* The loop terminates when any of the following conditions are met:
|
|
435
|
+
* - The LLM returns a response with no tool calls (natural end).
|
|
436
|
+
* - The maximum number of turns is reached.
|
|
437
|
+
* - The AbortSignal fires (e.g. user presses Ctrl+C).
|
|
438
|
+
* - An unrecoverable LLM API error occurs.
|
|
439
|
+
*
|
|
440
|
+
* @param userMessage - The new user message to process.
|
|
441
|
+
* @param history - Prior conversation messages (may be empty for a fresh session).
|
|
442
|
+
* @param options - Configuration for the loop.
|
|
443
|
+
* @returns The final conversation state, turn count, usage, and cost.
|
|
444
|
+
*/
|
|
445
|
+
export async function runAgentLoop(userMessage, history, options) {
|
|
446
|
+
const { router, toolRegistry, mode, maxTurns = DEFAULT_MAX_TURNS, model, cwd, nimbusInstructions, onText, onToolCallStart, onToolCallEnd, onToolOutputChunk, checkPermission, signal, } = options;
|
|
447
|
+
// -----------------------------------------------------------------------
|
|
448
|
+
// 1. Prepare tools and system prompt
|
|
449
|
+
// -----------------------------------------------------------------------
|
|
450
|
+
const tools = getToolsForMode(toolRegistry.getAll(), mode);
|
|
451
|
+
// H3: Auto-discover infra context if not provided and cwd is set (best-effort, cached per cwd)
|
|
452
|
+
let resolvedInfraContext = options.infraContext;
|
|
453
|
+
if (!resolvedInfraContext && cwd) {
|
|
454
|
+
try {
|
|
455
|
+
const { discoverInfraContext } = await import('../cli/init');
|
|
456
|
+
resolvedInfraContext = await Promise.race([
|
|
457
|
+
discoverInfraContext(cwd),
|
|
458
|
+
new Promise(r => setTimeout(() => r(undefined), 5000)),
|
|
459
|
+
]);
|
|
460
|
+
}
|
|
461
|
+
catch { /* best-effort */ }
|
|
462
|
+
}
|
|
463
|
+
const systemPrompt = buildSystemPrompt({
|
|
464
|
+
mode,
|
|
465
|
+
tools,
|
|
466
|
+
nimbusInstructions,
|
|
467
|
+
cwd,
|
|
468
|
+
infraContext: resolvedInfraContext,
|
|
469
|
+
dryRun: options.dryRun,
|
|
470
|
+
});
|
|
471
|
+
// Convert agentic ToolDefinitions to the LLM-level format expected by
|
|
472
|
+
// the router's routeWithTools() method (OpenAI function-calling shape).
|
|
473
|
+
const llmTools = tools.map(toOpenAITool);
|
|
474
|
+
// -----------------------------------------------------------------------
|
|
475
|
+
// 2. Initialize conversation state
|
|
476
|
+
// -----------------------------------------------------------------------
|
|
477
|
+
// PERF-4a: Capacity-hinted pre-allocation avoids repeated V8 array reallocation
|
|
478
|
+
// as messages accumulate during a long conversation.
|
|
479
|
+
const messages = new Array(Math.max(history.length + 1, 10));
|
|
480
|
+
messages.length = 0;
|
|
481
|
+
messages.push(...history, { role: 'user', content: userMessage });
|
|
482
|
+
let turns = 0;
|
|
483
|
+
let interrupted = false;
|
|
484
|
+
const totalUsage = {
|
|
485
|
+
promptTokens: 0,
|
|
486
|
+
completionTokens: 0,
|
|
487
|
+
totalTokens: 0,
|
|
488
|
+
};
|
|
489
|
+
let totalCost = 0;
|
|
490
|
+
// G3: Session-level destructive operation counter and per-turn tool call counter
|
|
491
|
+
let sessionDestructiveOps = 0;
|
|
492
|
+
const MAX_TOOL_CALLS_PER_TURN = options.maxToolCallsPerTurn ?? 20;
|
|
493
|
+
const MAX_DESTRUCTIVE_OPS_PER_SESSION = options.maxDestructiveOpsPerSession ?? 5;
|
|
494
|
+
// M2/M5: Track tool calls that have already received a credential-error retry message
|
|
495
|
+
// to avoid spamming the auth-refresh hint on repeated failures.
|
|
496
|
+
const credentialRetried = new Set();
|
|
497
|
+
// G8: Track which terraform workdirs have had a plan run in this session.
|
|
498
|
+
// Used to warn when apply is run without a prior plan.
|
|
499
|
+
const terraformPlannedWorkdirs = new Set();
|
|
500
|
+
// G10: One-time kubectl RBAC pre-flight check state.
|
|
501
|
+
// kubectlRbacChecked: ensures we only run `kubectl auth can-i --list` once per session.
|
|
502
|
+
// rbacPreamble: stores the RBAC output to inject into the first kubectl tool result.
|
|
503
|
+
let kubectlRbacChecked = false;
|
|
504
|
+
let rbacPreamble = '';
|
|
505
|
+
// G10: Pre-import async exec utilities so they're available inside the loop.
|
|
506
|
+
// Using async execFile avoids blocking the Node.js event loop for kubectl/terraform calls.
|
|
507
|
+
const { execFile: _execFile, exec: _exec } = await import('node:child_process');
|
|
508
|
+
const { promisify: _promisify } = await import('node:util');
|
|
509
|
+
const _execFileAsync = _promisify(_execFile);
|
|
510
|
+
const _execAsync = _promisify(_exec);
|
|
511
|
+
// PERF-4a: Pre-build the system message once so it can be reused every turn
|
|
512
|
+
// without allocating a new object on each loop iteration.
|
|
513
|
+
const _systemMessageObj = { role: 'system', content: systemPrompt };
|
|
514
|
+
// Shared mutable ref: set to true by 'apply-all' diff decision to skip further prompts
|
|
515
|
+
const skipRemainingDiffPrompts = { value: options.skipRemainingDiffPrompts ?? false };
|
|
516
|
+
// Shared mutable ref: set to true by 'reject-all' diff decision to auto-reject further prompts
|
|
517
|
+
const rejectRemainingDiffPrompts = { value: options.rejectRemainingDiffPrompts ?? false };
|
|
518
|
+
// -----------------------------------------------------------------------
|
|
519
|
+
// 3. Main agent loop
|
|
520
|
+
// -----------------------------------------------------------------------
|
|
521
|
+
while (turns < maxTurns) {
|
|
522
|
+
// Check for cancellation before each turn
|
|
523
|
+
if (signal?.aborted) {
|
|
524
|
+
interrupted = true;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
turns++;
|
|
528
|
+
try {
|
|
529
|
+
// Gap 18: Auto-route model based on task complexity when no explicit model set
|
|
530
|
+
let effectiveModel = model ?? DEFAULT_MODEL;
|
|
531
|
+
if (!model && options.autoRouteModel) {
|
|
532
|
+
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
533
|
+
const lastMsgText = lastUserMsg
|
|
534
|
+
? typeof lastUserMsg.content === 'string'
|
|
535
|
+
? lastUserMsg.content
|
|
536
|
+
: JSON.stringify(lastUserMsg.content)
|
|
537
|
+
: '';
|
|
538
|
+
const complexity = classifyTaskComplexity(lastMsgText);
|
|
539
|
+
effectiveModel = routeModel(complexity);
|
|
540
|
+
if (onText && turns === 1) {
|
|
541
|
+
onText(`\n[auto: ${effectiveModel.split('/').pop()?.replace('anthropic/', '') ?? effectiveModel}]\n`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Build the completion request with tool definitions.
|
|
545
|
+
// The systemMessageObj is pre-built before the loop (PERF-4a) — reuse it.
|
|
546
|
+
const allMessages = new Array(messages.length + 1);
|
|
547
|
+
allMessages.length = 0;
|
|
548
|
+
allMessages.push(_systemMessageObj, ...messages);
|
|
549
|
+
const request = {
|
|
550
|
+
messages: allMessages,
|
|
551
|
+
model: effectiveModel,
|
|
552
|
+
tools: llmTools,
|
|
553
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
554
|
+
};
|
|
555
|
+
// Stream text tokens incrementally via routeStreamWithTools.
|
|
556
|
+
// Tokens are forwarded to onText as they arrive; tool calls
|
|
557
|
+
// are accumulated from the final chunk.
|
|
558
|
+
let responseContent = '';
|
|
559
|
+
let responseToolCalls;
|
|
560
|
+
let responseUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
561
|
+
// A1: Retry on transient errors (rate-limit / 5xx) with exponential backoff
|
|
562
|
+
const MAX_STREAM_RETRIES = 2;
|
|
563
|
+
let streamAttempt = 0;
|
|
564
|
+
while (true) {
|
|
565
|
+
// A2: Silence timeout — abort if no chunk arrives (G21: configurable)
|
|
566
|
+
const STREAM_SILENCE_MS = options.streamSilenceTimeoutMs ?? 60_000;
|
|
567
|
+
const silenceAbort = new AbortController();
|
|
568
|
+
let silenceTimer;
|
|
569
|
+
const resetSilence = () => {
|
|
570
|
+
clearTimeout(silenceTimer);
|
|
571
|
+
silenceTimer = setTimeout(() => silenceAbort.abort('Stream timeout'), STREAM_SILENCE_MS);
|
|
572
|
+
};
|
|
573
|
+
resetSilence();
|
|
574
|
+
try {
|
|
575
|
+
// Pass silence abort signal via request cast (non-standard but supported by most providers)
|
|
576
|
+
const requestWithSignal = { ...request, signal: silenceAbort.signal };
|
|
577
|
+
for await (const chunk of router.routeStreamWithTools(requestWithSignal)) {
|
|
578
|
+
resetSilence(); // reset on every chunk
|
|
579
|
+
if (chunk.content) {
|
|
580
|
+
responseContent += chunk.content;
|
|
581
|
+
if (onText) {
|
|
582
|
+
onText(chunk.content);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (chunk.toolCallStart && onText) {
|
|
586
|
+
// Show early feedback when the LLM starts composing a tool call
|
|
587
|
+
onText(`\n[Preparing tool: ${chunk.toolCallStart.name}...]\n`);
|
|
588
|
+
}
|
|
589
|
+
if (chunk.toolCalls) {
|
|
590
|
+
responseToolCalls = chunk.toolCalls;
|
|
591
|
+
}
|
|
592
|
+
if (chunk.usage) {
|
|
593
|
+
responseUsage = chunk.usage;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
clearTimeout(silenceTimer);
|
|
597
|
+
break; // success — exit retry loop
|
|
598
|
+
}
|
|
599
|
+
catch (streamErr) {
|
|
600
|
+
clearTimeout(silenceTimer);
|
|
601
|
+
if (streamAttempt < MAX_STREAM_RETRIES && isRetryableStreamError(streamErr)) {
|
|
602
|
+
const delay = 1000 * Math.pow(2, streamAttempt);
|
|
603
|
+
if (onText) {
|
|
604
|
+
onText(`\n[Retrying after error (attempt ${streamAttempt + 1})...]\n`);
|
|
605
|
+
}
|
|
606
|
+
await new Promise(r => setTimeout(r, delay));
|
|
607
|
+
streamAttempt++;
|
|
608
|
+
// Reset partial accumulation before retry
|
|
609
|
+
responseContent = '';
|
|
610
|
+
responseToolCalls = undefined;
|
|
611
|
+
responseUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// G24: Graceful network error message instead of raw Node.js error
|
|
615
|
+
const streamErrObj = streamErr;
|
|
616
|
+
const isNetworkError = /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed|network/i.test(streamErrObj?.message ?? '');
|
|
617
|
+
if (isNetworkError) {
|
|
618
|
+
const netMsg = '\n[!!] Network unreachable — cannot reach the LLM API.\nCheck your internet connection and API key validity, then try again.\n';
|
|
619
|
+
if (onText)
|
|
620
|
+
onText(netMsg);
|
|
621
|
+
// Re-throw a specially-marked error so the outer turn catch block can handle it
|
|
622
|
+
const netErr = new Error(netMsg);
|
|
623
|
+
netErr._nimbusNetworkError = true;
|
|
624
|
+
throw netErr;
|
|
625
|
+
}
|
|
626
|
+
throw streamErr; // non-retryable — propagate to outer catch
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// Accumulate usage and cost
|
|
630
|
+
totalUsage.promptTokens += responseUsage.promptTokens;
|
|
631
|
+
totalUsage.completionTokens += responseUsage.completionTokens;
|
|
632
|
+
totalUsage.totalTokens += responseUsage.totalTokens;
|
|
633
|
+
// Estimate cost for this turn
|
|
634
|
+
const resolvedModel = effectiveModel;
|
|
635
|
+
const providerName = resolvedModel.includes('/') ? resolvedModel.split('/')[0] : 'anthropic';
|
|
636
|
+
const modelName = resolvedModel.includes('/')
|
|
637
|
+
? resolvedModel.split('/').slice(1).join('/')
|
|
638
|
+
: resolvedModel;
|
|
639
|
+
const turnCost = calculateCost(providerName, modelName, responseUsage.promptTokens, responseUsage.completionTokens);
|
|
640
|
+
totalCost += turnCost.costUSD;
|
|
641
|
+
// Notify caller of accumulated usage/cost after each turn
|
|
642
|
+
if (options.onUsage) {
|
|
643
|
+
options.onUsage(totalUsage, totalCost);
|
|
644
|
+
}
|
|
645
|
+
// M2: Emit per-turn token/cost stats as a dim system message in the TUI.
|
|
646
|
+
// Only emit when there was actual token usage (skip turns with 0 tokens).
|
|
647
|
+
if (onText && (responseUsage.promptTokens > 0 || responseUsage.completionTokens > 0)) {
|
|
648
|
+
const statsLine = `\n[${responseUsage.promptTokens} in / ${responseUsage.completionTokens} out — $${turnCost.costUSD.toFixed(4)}]\n`;
|
|
649
|
+
onText(statsLine);
|
|
650
|
+
}
|
|
651
|
+
// G16: Cost budget enforcement — stop if cumulative cost exceeds the limit
|
|
652
|
+
if (options.costBudgetUSD !== undefined && totalCost >= options.costBudgetUSD) {
|
|
653
|
+
const budgetMsg = `\n\n[!!] Cost budget of $${options.costBudgetUSD.toFixed(2)} reached (used: $${totalCost.toFixed(3)}). Stopping to prevent overspend.\n`;
|
|
654
|
+
if (onText)
|
|
655
|
+
onText(budgetMsg);
|
|
656
|
+
messages.push({ role: 'assistant', content: budgetMsg });
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
// -----------------------------------------------------------------
|
|
660
|
+
// No tool calls → the LLM is done
|
|
661
|
+
// -----------------------------------------------------------------
|
|
662
|
+
if (!responseToolCalls || responseToolCalls.length === 0) {
|
|
663
|
+
messages.push({
|
|
664
|
+
role: 'assistant',
|
|
665
|
+
content: responseContent,
|
|
666
|
+
});
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
// -----------------------------------------------------------------
|
|
670
|
+
// Tool calls present → execute each one
|
|
671
|
+
// -----------------------------------------------------------------
|
|
672
|
+
// Append the assistant message that contains the tool calls
|
|
673
|
+
messages.push({
|
|
674
|
+
role: 'assistant',
|
|
675
|
+
content: responseContent,
|
|
676
|
+
toolCalls: responseToolCalls,
|
|
677
|
+
});
|
|
678
|
+
// G3: Per-turn tool call counter — reset at the start of each tool-call batch
|
|
679
|
+
let turnToolCallCount = 0;
|
|
680
|
+
// H2: Parallel dispatch for read-only tools (safe to run concurrently)
|
|
681
|
+
const READ_ONLY_TOOLS = new Set([
|
|
682
|
+
'read_file', 'glob', 'grep', 'cloud_discover', 'terraform_plan_analyze',
|
|
683
|
+
'kubectl_context', 'helm_values', 'cost_estimate', 'drift_detect',
|
|
684
|
+
]);
|
|
685
|
+
const canRunInParallel = (tc) => READ_ONLY_TOOLS.has(tc.function.name);
|
|
686
|
+
const allReadOnly = responseToolCalls.every(canRunInParallel);
|
|
687
|
+
if (allReadOnly && responseToolCalls.length > 1) {
|
|
688
|
+
// All tools are read-only — dispatch in parallel
|
|
689
|
+
const parallelChunkCallback = onToolOutputChunk
|
|
690
|
+
? (id) => (chunk) => onToolOutputChunk(id, chunk)
|
|
691
|
+
: undefined;
|
|
692
|
+
const parallelResults = await Promise.allSettled(responseToolCalls.map(tc => executeToolCall(tc, toolRegistry, onToolCallStart, onToolCallEnd, checkPermission, options.lspManager, options.snapshotManager, options.sessionId, signal, options.hookEngine, mode, options.requestFileDiff, skipRemainingDiffPrompts, rejectRemainingDiffPrompts, parallelChunkCallback ? parallelChunkCallback(tc.id) : undefined, options.toolTimeouts, options.infraContext)));
|
|
693
|
+
for (let pi = 0; pi < responseToolCalls.length; pi++) {
|
|
694
|
+
const tc = responseToolCalls[pi];
|
|
695
|
+
const pResult = parallelResults[pi];
|
|
696
|
+
const pContent = pResult.status === 'fulfilled'
|
|
697
|
+
? (pResult.value.isError ? `Error: ${pResult.value.error}` : pResult.value.output)
|
|
698
|
+
: `Error: ${pResult.reason}`;
|
|
699
|
+
messages.push({ role: 'tool', toolCallId: tc.id, name: tc.function.name, content: pContent });
|
|
700
|
+
}
|
|
701
|
+
// Skip sequential processing — jump directly to next LLM turn
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
// Process tool calls sequentially (order may matter for side effects)
|
|
705
|
+
for (const toolCall of responseToolCalls) {
|
|
706
|
+
// Check for cancellation between tool calls
|
|
707
|
+
if (signal?.aborted) {
|
|
708
|
+
interrupted = true;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
// G3: Enforce per-turn tool call limit to prevent runaway loops
|
|
712
|
+
turnToolCallCount++;
|
|
713
|
+
if (turnToolCallCount > MAX_TOOL_CALLS_PER_TURN) {
|
|
714
|
+
messages.push({
|
|
715
|
+
role: 'tool',
|
|
716
|
+
toolCallId: toolCall.id,
|
|
717
|
+
name: toolCall.function.name,
|
|
718
|
+
content: `[Tool limit reached: ${MAX_TOOL_CALLS_PER_TURN} tool calls in this turn. Summarizing progress and stopping to avoid runaway execution.]`,
|
|
719
|
+
});
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
// G3: Count destructive operations at the session level
|
|
723
|
+
if (isDestructiveOp(toolCall.function.name, toolCall.function.arguments)) {
|
|
724
|
+
sessionDestructiveOps++;
|
|
725
|
+
}
|
|
726
|
+
// G10: One-time kubectl RBAC pre-flight check — runs before the first kubectl call
|
|
727
|
+
// in this session. Stores the RBAC permissions summary in rbacPreamble so it can
|
|
728
|
+
// be injected into the first kubectl tool result (keeps conversation structure valid).
|
|
729
|
+
// Uses async execFile to avoid blocking the Node.js event loop (up to 5s call).
|
|
730
|
+
if (!kubectlRbacChecked && toolCall.function.name === 'kubectl') {
|
|
731
|
+
kubectlRbacChecked = true;
|
|
732
|
+
try {
|
|
733
|
+
const { stdout: rbacOut } = await _execFileAsync('kubectl', ['auth', 'can-i', '--list'], {
|
|
734
|
+
encoding: 'utf-8', timeout: 5000,
|
|
735
|
+
});
|
|
736
|
+
const truncated = rbacOut.length > 1500
|
|
737
|
+
? `${rbacOut.slice(0, 1500)}\n...[truncated]`
|
|
738
|
+
: rbacOut;
|
|
739
|
+
rbacPreamble = `[kubectl RBAC context: permissions available in current context]\n${truncated}\n\n`;
|
|
740
|
+
}
|
|
741
|
+
catch { /* non-critical — RBAC check failure does not block kubectl */ }
|
|
742
|
+
}
|
|
743
|
+
// M6: Destructive action guard — inject warning into LLM context before executing
|
|
744
|
+
try {
|
|
745
|
+
const m6Input = JSON.parse(toolCall.function.arguments);
|
|
746
|
+
const destructiveWarning = isDestructiveAction(toolCall.function.name, m6Input);
|
|
747
|
+
if (destructiveWarning) {
|
|
748
|
+
messages.push({
|
|
749
|
+
role: 'tool',
|
|
750
|
+
toolCallId: toolCall.id + '-guard',
|
|
751
|
+
name: toolCall.function.name,
|
|
752
|
+
content: `[SAFETY] ${destructiveWarning}`,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch { /* ignore parse errors */ }
|
|
757
|
+
// Build chunk callback that forwards tool output to the TUI in real-time
|
|
758
|
+
const chunkCallback = onToolOutputChunk
|
|
759
|
+
? (chunk) => onToolOutputChunk(toolCall.id, chunk)
|
|
760
|
+
: undefined;
|
|
761
|
+
const result = await executeToolCall(toolCall, toolRegistry, onToolCallStart, onToolCallEnd, checkPermission, options.lspManager, options.snapshotManager, options.sessionId, signal, options.hookEngine, mode, options.requestFileDiff, skipRemainingDiffPrompts, rejectRemainingDiffPrompts, chunkCallback, options.toolTimeouts, options.infraContext);
|
|
762
|
+
// Append each tool result as a separate message so the LLM can
|
|
763
|
+
// match it to the corresponding tool_use block by toolCallId.
|
|
764
|
+
let toolContent = result.isError ? `Error: ${result.error}` : result.output;
|
|
765
|
+
// G10: Inject RBAC context preamble into the first kubectl result
|
|
766
|
+
if (rbacPreamble && toolCall.function.name === 'kubectl') {
|
|
767
|
+
toolContent = rbacPreamble + toolContent;
|
|
768
|
+
rbacPreamble = ''; // consume once — only injected into the first kubectl result
|
|
769
|
+
}
|
|
770
|
+
// Inject DevOps error classification hints to guide self-correction
|
|
771
|
+
if (result.isError && result.error) {
|
|
772
|
+
const hint = classifyDevOpsError(toolCall.function.name, result.error, options.nimbusInstructions);
|
|
773
|
+
if (hint) {
|
|
774
|
+
toolContent += `\n\n${hint}`;
|
|
775
|
+
// C4: Also show hint in TUI error output (not just LLM context)
|
|
776
|
+
result.output += `\n\n${hint}`;
|
|
777
|
+
// M2/M5: Auto-retry signal on credential expiry errors
|
|
778
|
+
// If the classified hint indicates a credential/auth problem, append
|
|
779
|
+
// a structured prompt so the agent knows to run auth-refresh, and
|
|
780
|
+
// set provider-specific env hints for the auth-refresh command.
|
|
781
|
+
const isCredentialError = hint.toLowerCase().includes('credential') ||
|
|
782
|
+
hint.toLowerCase().includes('expired') ||
|
|
783
|
+
hint.toLowerCase().includes('auth') ||
|
|
784
|
+
hint.toLowerCase().includes('login required');
|
|
785
|
+
if (isCredentialError && !credentialRetried.has(toolCall.id ?? toolCall.function.name)) {
|
|
786
|
+
credentialRetried.add(toolCall.id ?? toolCall.function.name);
|
|
787
|
+
// M5: Set provider-specific refresh hint env vars so auth-refresh
|
|
788
|
+
// can surface targeted guidance when invoked by the user.
|
|
789
|
+
const errorLower = (result.error ?? '').toLowerCase();
|
|
790
|
+
if (errorLower.includes('aws')) {
|
|
791
|
+
process.env.NIMBUS_AWS_REFRESH_HINT = '1';
|
|
792
|
+
}
|
|
793
|
+
if (errorLower.includes('gcp') || errorLower.includes('google')) {
|
|
794
|
+
process.env.NIMBUS_GCP_REFRESH_HINT = '1';
|
|
795
|
+
}
|
|
796
|
+
if (errorLower.includes('azure')) {
|
|
797
|
+
process.env.NIMBUS_AZURE_REFRESH_HINT = '1';
|
|
798
|
+
}
|
|
799
|
+
const refreshMsg = [
|
|
800
|
+
'[!!] Credential expired. Run: nimbus auth-refresh',
|
|
801
|
+
'[Nimbus] Credential error detected on tool: ' + toolCall.function.name,
|
|
802
|
+
'Run "nimbus auth-refresh" to refresh cloud credentials, then retry.',
|
|
803
|
+
].join('\n');
|
|
804
|
+
toolContent += '\n\n' + refreshMsg;
|
|
805
|
+
result.output += '\n\n' + refreshMsg;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
else if (DEVOPS_TOOL_NAMES.has(toolCall.function.name)) {
|
|
809
|
+
// Unknown DevOps error — provide structured self-diagnosis steps
|
|
810
|
+
toolContent += [
|
|
811
|
+
'\n\n--- Self-Diagnosis Steps ---',
|
|
812
|
+
'1. Check tool is installed: `which terraform` / `kubectl version` / `helm version`',
|
|
813
|
+
'2. Check credentials: `aws sts get-caller-identity` / `gcloud auth list` / `az account show`',
|
|
814
|
+
'3. Check network connectivity to the cluster/cloud provider',
|
|
815
|
+
'4. Retry with verbose flag if available (e.g., TF_LOG=DEBUG, kubectl --v=6)',
|
|
816
|
+
'5. If the error persists, report the exact error message and the command that caused it.',
|
|
817
|
+
].join('\n');
|
|
818
|
+
}
|
|
819
|
+
// M4: Track recurring errors and persist to NIMBUS.md after 3 occurrences
|
|
820
|
+
const m4Hint = classifyDevOpsError(toolCall.function.name, result.error ?? '', options.nimbusInstructions);
|
|
821
|
+
if (m4Hint) {
|
|
822
|
+
trackAndPersistError(toolCall.function.name, m4Hint, options.cwd ?? process.cwd());
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// H5: Inject cost delta hint after successful infra operations
|
|
826
|
+
if (!result.isError) {
|
|
827
|
+
try {
|
|
828
|
+
const h5Input = JSON.parse(toolCall.function.arguments);
|
|
829
|
+
const costHint = extractCostHintFromToolOutput(toolCall.function.name, h5Input, result.output);
|
|
830
|
+
if (costHint) {
|
|
831
|
+
onText?.(`\n[cost] ${costHint}\n`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
catch { /* ignore parse errors */ }
|
|
835
|
+
}
|
|
836
|
+
// L6: Auto-generate runbook after terraform apply success
|
|
837
|
+
if (!result.isError && toolCall.function.name === 'terraform') {
|
|
838
|
+
try {
|
|
839
|
+
const l6Input = JSON.parse(toolCall.function.arguments);
|
|
840
|
+
if (String(l6Input.action) === 'apply') {
|
|
841
|
+
const l6Match = result.output.match(/Resources:\s*(\d+) added/);
|
|
842
|
+
if (l6Match && parseInt(l6Match[1] ?? '0', 10) > 0) {
|
|
843
|
+
const { join: _l6Join } = require('node:path');
|
|
844
|
+
const { homedir: _l6Homedir } = require('node:os');
|
|
845
|
+
const { mkdirSync: _l6MkdirSync, writeFileSync: _l6WriteFileSync } = require('node:fs');
|
|
846
|
+
const runbookDir = _l6Join(_l6Homedir(), '.nimbus', 'runbooks');
|
|
847
|
+
_l6MkdirSync(runbookDir, { recursive: true });
|
|
848
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
849
|
+
const runbookPath = _l6Join(runbookDir, `terraform-apply-${ts}.md`);
|
|
850
|
+
const runbookContent = [
|
|
851
|
+
'# Terraform Apply Runbook',
|
|
852
|
+
'',
|
|
853
|
+
`Date: ${new Date().toLocaleString()}`,
|
|
854
|
+
'',
|
|
855
|
+
'Apply output:',
|
|
856
|
+
'```',
|
|
857
|
+
result.output.slice(0, 2000),
|
|
858
|
+
'```',
|
|
859
|
+
'',
|
|
860
|
+
'## Rollback',
|
|
861
|
+
'',
|
|
862
|
+
'To rollback, run `terraform destroy` or restore from a previous state.',
|
|
863
|
+
].join('\n');
|
|
864
|
+
_l6WriteFileSync(runbookPath, runbookContent, 'utf-8');
|
|
865
|
+
options.onText?.(`\n[runbook] Saved to ${runbookPath}\n`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch { /* non-critical */ }
|
|
870
|
+
}
|
|
871
|
+
// GAP-25: Structured audit trail for destructive operations
|
|
872
|
+
if (!result.isError && isDestructiveOp(toolCall.function.name, toolCall.function.arguments)) {
|
|
873
|
+
try {
|
|
874
|
+
const { appendFileSync, mkdirSync } = await import('node:fs');
|
|
875
|
+
const { homedir } = await import('node:os');
|
|
876
|
+
const { join } = await import('node:path');
|
|
877
|
+
const auditDir = join(homedir(), '.nimbus');
|
|
878
|
+
mkdirSync(auditDir, { recursive: true });
|
|
879
|
+
const event = JSON.stringify({
|
|
880
|
+
type: 'infra-change',
|
|
881
|
+
tool: toolCall.function.name,
|
|
882
|
+
action: JSON.parse(toolCall.function.arguments).action,
|
|
883
|
+
sessionId: options.sessionId ?? 'unknown',
|
|
884
|
+
cwd: options.cwd ?? process.cwd(),
|
|
885
|
+
timestamp: new Date().toISOString(),
|
|
886
|
+
});
|
|
887
|
+
appendFileSync(join(auditDir, 'audit.jsonl'), event + '\n', 'utf-8');
|
|
888
|
+
}
|
|
889
|
+
catch { /* audit logging is non-critical */ }
|
|
890
|
+
}
|
|
891
|
+
// G3: Append a warning when session-level destructive op threshold is reached
|
|
892
|
+
if (sessionDestructiveOps >= MAX_DESTRUCTIVE_OPS_PER_SESSION) {
|
|
893
|
+
toolContent += `\n\n[Warning: ${sessionDestructiveOps} destructive operations executed in this session. Review changes carefully.]`;
|
|
894
|
+
}
|
|
895
|
+
// Cache terraform plan output so a subsequent apply can reference it.
|
|
896
|
+
// Also track planned workdirs (G8) and warn on unplanned applies.
|
|
897
|
+
if (toolCall.function.name === 'terraform' && !result.isError) {
|
|
898
|
+
try {
|
|
899
|
+
const tfArgs = JSON.parse(toolCall.function.arguments);
|
|
900
|
+
if (tfArgs.action === 'plan' && tfArgs.workdir) {
|
|
901
|
+
cacheTerraformPlan(String(tfArgs.workdir), result.output);
|
|
902
|
+
// G8: Track that a plan was run for this workdir in this session
|
|
903
|
+
terraformPlannedWorkdirs.add(String(tfArgs.workdir));
|
|
904
|
+
}
|
|
905
|
+
// G8: Warn if apply ran without a prior plan in this session
|
|
906
|
+
if (tfArgs.action === 'apply' && tfArgs.workdir && !terraformPlannedWorkdirs.has(String(tfArgs.workdir))) {
|
|
907
|
+
toolContent = `[Note: terraform apply ran without a prior terraform plan in this session for ${String(tfArgs.workdir)}. Always run terraform plan first to review changes before applying.]\n\n${toolContent}`;
|
|
908
|
+
}
|
|
909
|
+
// Inject cached plan into apply context for the LLM
|
|
910
|
+
if (tfArgs.action === 'apply' && tfArgs.workdir) {
|
|
911
|
+
const cached = getCachedTerraformPlan(String(tfArgs.workdir));
|
|
912
|
+
if (cached) {
|
|
913
|
+
toolContent = `[Apply succeeded. This was the plan that was applied:]\n${cached.slice(0, 3000)}\n\n[Apply output:]\n${toolContent}`;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch { /* ignore parse errors */ }
|
|
918
|
+
}
|
|
919
|
+
// GAP-11: trigger FileDiff UI after terraform plan shows resource changes
|
|
920
|
+
if (toolCall.function.name === 'terraform' && !result.isError && options.requestFileDiff) {
|
|
921
|
+
try {
|
|
922
|
+
const tfArgs11 = JSON.parse(toolCall.function.arguments);
|
|
923
|
+
if (tfArgs11.action === 'plan') {
|
|
924
|
+
const { parseTerraformPlanOutput, buildFileDiffBatchFromPlan } = await import('./deploy-preview');
|
|
925
|
+
const changes = parseTerraformPlanOutput(toolContent);
|
|
926
|
+
if (changes.length > 0) {
|
|
927
|
+
const batchFiles = buildFileDiffBatchFromPlan({ changes });
|
|
928
|
+
for (const file of batchFiles) {
|
|
929
|
+
const decision = await options.requestFileDiff(file.filePath, file.toolName ?? 'terraform', file.diff ?? '');
|
|
930
|
+
if (decision === 'reject-all')
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
catch { /* non-critical — FileDiff UI not always available */ }
|
|
937
|
+
}
|
|
938
|
+
// GAP-18: auto-validate terraform files after write/edit tool calls
|
|
939
|
+
if (['write_file', 'edit_file', 'multi_edit'].includes(toolCall.function.name) && !result.isError) {
|
|
940
|
+
const gap18Input = JSON.parse(toolCall.function.arguments);
|
|
941
|
+
const gap18FilePath = gap18Input.path ?? gap18Input.file_path ?? '';
|
|
942
|
+
if (gap18FilePath.endsWith('.tf')) {
|
|
943
|
+
try {
|
|
944
|
+
// Use async exec to avoid blocking the event loop (up to 10s for terraform validate)
|
|
945
|
+
const { stdout: validateOut } = await _execAsync('terraform validate -json 2>/dev/null', {
|
|
946
|
+
cwd: options.cwd ?? process.cwd(),
|
|
947
|
+
encoding: 'utf-8',
|
|
948
|
+
timeout: 10_000,
|
|
949
|
+
});
|
|
950
|
+
const parsed = JSON.parse(validateOut);
|
|
951
|
+
if (!parsed.valid && parsed.diagnostics && parsed.diagnostics.length > 0) {
|
|
952
|
+
const errors = parsed.diagnostics
|
|
953
|
+
.filter(d => d.severity === 'error')
|
|
954
|
+
.map(d => ` ${d.summary}: ${d.detail}`)
|
|
955
|
+
.join('\n');
|
|
956
|
+
toolContent += `\n\nTerraform validation errors (please fix):\n${errors}`;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
catch { /* terraform not available or not in tf project — ignore */ }
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Truncate excessively large tool outputs to prevent context overflow
|
|
963
|
+
if (toolContent.length > MAX_TOOL_OUTPUT_CHARS) {
|
|
964
|
+
let head;
|
|
965
|
+
let tail;
|
|
966
|
+
let omitted;
|
|
967
|
+
const lines = toolContent.split('\n');
|
|
968
|
+
// C3: Smart truncation for terraform plan — preserve all diff lines
|
|
969
|
+
const isTerraformPlan = toolCall.function.name === 'terraform' && (() => {
|
|
970
|
+
try {
|
|
971
|
+
const tfArgs = JSON.parse(toolCall.function.arguments);
|
|
972
|
+
return tfArgs.action === 'plan';
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
})();
|
|
978
|
+
if (isTerraformPlan) {
|
|
979
|
+
// Keep all diff lines (create/update/destroy/replace) and the plan summary
|
|
980
|
+
const diffLines = [];
|
|
981
|
+
const contextLines = [];
|
|
982
|
+
for (const line of lines) {
|
|
983
|
+
const trimmed = line.trimStart();
|
|
984
|
+
const isDiffLine = trimmed.startsWith('+') || trimmed.startsWith('-') ||
|
|
985
|
+
trimmed.startsWith('~') || trimmed.startsWith('!') ||
|
|
986
|
+
line.includes('will be created') || line.includes('will be destroyed') ||
|
|
987
|
+
line.includes('will be updated') || line.includes('will be replaced') ||
|
|
988
|
+
line.includes('Plan:') || line.includes('No changes') ||
|
|
989
|
+
line.includes('Error:') || line.includes('Warning:');
|
|
990
|
+
if (isDiffLine) {
|
|
991
|
+
diffLines.push(line);
|
|
992
|
+
}
|
|
993
|
+
else {
|
|
994
|
+
contextLines.push(line);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// Allow up to 500 diff lines + first 50 context lines
|
|
998
|
+
const keptDiff = diffLines.slice(0, 500);
|
|
999
|
+
const keptCtx = contextLines.slice(0, 50);
|
|
1000
|
+
omitted = Math.max(0, lines.length - keptDiff.length - keptCtx.length);
|
|
1001
|
+
head = [...keptCtx, ...keptDiff].join('\n');
|
|
1002
|
+
tail = '';
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
const headLines = 100, tailLines = 20;
|
|
1006
|
+
head = lines.slice(0, headLines).join('\n');
|
|
1007
|
+
tail = lines.slice(-tailLines).join('\n');
|
|
1008
|
+
omitted = Math.max(0, lines.length - headLines - tailLines);
|
|
1009
|
+
}
|
|
1010
|
+
// Save full output to disk for reference
|
|
1011
|
+
try {
|
|
1012
|
+
const { mkdirSync: _mkdirSync, writeFileSync: _writeFileSync } = await import('node:fs');
|
|
1013
|
+
const { homedir: _homedir } = await import('node:os');
|
|
1014
|
+
const outDir = join(_homedir(), '.nimbus', 'tool-outputs');
|
|
1015
|
+
_mkdirSync(outDir, { recursive: true });
|
|
1016
|
+
const outFile = join(outDir, `${Date.now()}-${toolCall.function.name}.log`);
|
|
1017
|
+
_writeFileSync(outFile, toolContent, 'utf-8');
|
|
1018
|
+
toolContent = omitted > 0
|
|
1019
|
+
? `${head}${tail ? '\n\n... [' + omitted + ' lines omitted — full output saved to ' + outFile + '] ...\n\n' + tail : '\n\n... [full output saved to ' + outFile + ']'}`
|
|
1020
|
+
: `${head}${tail ? '\n\n' + tail : ''}`;
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
toolContent = omitted > 0
|
|
1024
|
+
? `${head}${tail ? '\n\n... [' + omitted + ' lines omitted — output too large for context] ...\n\n' + tail : '\n\n... [' + omitted + ' lines omitted]'}`
|
|
1025
|
+
: `${head}${tail ? '\n\n' + tail : ''}`;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
messages.push({
|
|
1029
|
+
role: 'tool',
|
|
1030
|
+
toolCallId: toolCall.id,
|
|
1031
|
+
name: toolCall.function.name,
|
|
1032
|
+
content: toolContent,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
// If we broke out of the tool-call loop due to cancellation, exit
|
|
1036
|
+
// the main loop as well.
|
|
1037
|
+
if (interrupted) {
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
// -----------------------------------------------------------------
|
|
1041
|
+
// Auto-compact check
|
|
1042
|
+
// -----------------------------------------------------------------
|
|
1043
|
+
// After tool results are appended, check whether the conversation
|
|
1044
|
+
// has grown past the context window threshold. If so, summarize
|
|
1045
|
+
// older messages to free up space for future turns.
|
|
1046
|
+
if (options.contextManager) {
|
|
1047
|
+
const toolTokens = llmTools.reduce((sum, t) => sum + Math.ceil(JSON.stringify(t).length / 4), 0);
|
|
1048
|
+
if (options.contextManager.shouldCompact(systemPrompt, messages, toolTokens)) {
|
|
1049
|
+
try {
|
|
1050
|
+
const compactResult = await runCompaction(messages, options.contextManager, {
|
|
1051
|
+
router,
|
|
1052
|
+
...(options.infraContext ? { infraContext: options.infraContext } : {}),
|
|
1053
|
+
});
|
|
1054
|
+
// Replace messages with the compacted version
|
|
1055
|
+
messages.length = 0;
|
|
1056
|
+
messages.push(...compactResult.messages);
|
|
1057
|
+
// Clear the token cache after compaction — old message entries are no longer valid
|
|
1058
|
+
options.contextManager.clearTokenCache();
|
|
1059
|
+
if (options.onCompact) {
|
|
1060
|
+
options.onCompact(compactResult.result);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
catch (compactErr) {
|
|
1064
|
+
// Compaction failed — notify user visibly and continue with original messages
|
|
1065
|
+
const compactErrMsg = compactErr instanceof Error ? compactErr.message : String(compactErr);
|
|
1066
|
+
if (onText) {
|
|
1067
|
+
onText(`\n[Warning: Auto-compaction failed: ${compactErrMsg}. Context may exceed budget on the next turn.]\n`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
// LLM API error — report to the caller and break
|
|
1075
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1076
|
+
// G24: Network errors already printed via onText above — skip duplicate output
|
|
1077
|
+
const isNetworkErr = (error instanceof Error) && error._nimbusNetworkError;
|
|
1078
|
+
if (!isNetworkErr && onText) {
|
|
1079
|
+
onText(`\n[Error: ${msg}]\n`);
|
|
1080
|
+
}
|
|
1081
|
+
messages.push({
|
|
1082
|
+
role: 'assistant',
|
|
1083
|
+
content: isNetworkErr ? msg : `I encountered an error: ${msg}`,
|
|
1084
|
+
});
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// -----------------------------------------------------------------------
|
|
1089
|
+
// 4. Post-loop bookkeeping
|
|
1090
|
+
// -----------------------------------------------------------------------
|
|
1091
|
+
if (turns >= maxTurns && !interrupted) {
|
|
1092
|
+
if (onText) {
|
|
1093
|
+
onText(`\n[Agent reached maximum turns limit (${maxTurns}). Stopping.]\n`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// GAP-19: Session summary after multi-step deploy
|
|
1097
|
+
if (options.mode === 'deploy' && options.onText) {
|
|
1098
|
+
// Collect tool calls from messages
|
|
1099
|
+
const allToolCalls = [];
|
|
1100
|
+
for (const msg of messages) {
|
|
1101
|
+
if (msg.role === 'assistant' && Array.isArray(msg.toolCalls)) {
|
|
1102
|
+
for (const tc of msg.toolCalls) {
|
|
1103
|
+
try {
|
|
1104
|
+
allToolCalls.push({ name: tc.function.name, input: JSON.parse(tc.function.arguments) });
|
|
1105
|
+
}
|
|
1106
|
+
catch { /* ignore */ }
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (allToolCalls.length > 3) {
|
|
1111
|
+
const terraform = allToolCalls.filter(c => c.name === 'terraform');
|
|
1112
|
+
const kubectl = allToolCalls.filter(c => c.name === 'kubectl');
|
|
1113
|
+
const helm = allToolCalls.filter(c => c.name === 'helm');
|
|
1114
|
+
const summaryLines = ['---', '**Session Summary**'];
|
|
1115
|
+
if (terraform.length)
|
|
1116
|
+
summaryLines.push(`• Terraform: ${terraform.map(c => String(c.input.action ?? '')).join(', ')}`);
|
|
1117
|
+
if (kubectl.length)
|
|
1118
|
+
summaryLines.push(`• Kubectl: ${kubectl.map(c => String(c.input.action ?? '')).join(', ')}`);
|
|
1119
|
+
if (helm.length)
|
|
1120
|
+
summaryLines.push(`• Helm: ${helm.map(c => String(c.input.action ?? '')).join(', ')}`);
|
|
1121
|
+
if (summaryLines.length > 2) {
|
|
1122
|
+
options.onText('\n\n' + summaryLines.join('\n'));
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
messages,
|
|
1128
|
+
turns,
|
|
1129
|
+
interrupted,
|
|
1130
|
+
usage: totalUsage,
|
|
1131
|
+
totalCost,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// Tool Execution
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
/** Tools that modify files and should trigger LSP diagnostics. */
|
|
1138
|
+
const FILE_EDITING_TOOLS = new Set(['edit_file', 'multi_edit', 'write_file']);
|
|
1139
|
+
/** Tools that mutate files and may require a pre-approval diff. */
|
|
1140
|
+
const FILE_MUTATING_TOOLS = new Set(['edit_file', 'multi_edit', 'write_file']);
|
|
1141
|
+
/**
|
|
1142
|
+
* Generate a simple unified diff between two strings.
|
|
1143
|
+
* Suitable for display; uses a greedy line-by-line approach.
|
|
1144
|
+
*/
|
|
1145
|
+
function generateUnifiedDiff(filename, before, after) {
|
|
1146
|
+
const beforeLines = before.split('\n');
|
|
1147
|
+
const afterLines = after.split('\n');
|
|
1148
|
+
const lines = [`--- a/${filename}`, `+++ b/${filename}`];
|
|
1149
|
+
let i = 0;
|
|
1150
|
+
let j = 0;
|
|
1151
|
+
while (i < beforeLines.length || j < afterLines.length) {
|
|
1152
|
+
if (beforeLines[i] === afterLines[j]) {
|
|
1153
|
+
i++;
|
|
1154
|
+
j++;
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const hunkBefore = [];
|
|
1158
|
+
const hunkAfter = [];
|
|
1159
|
+
const start = i;
|
|
1160
|
+
while (i < beforeLines.length && beforeLines[i] !== afterLines[j]) {
|
|
1161
|
+
hunkBefore.push(beforeLines[i++]);
|
|
1162
|
+
}
|
|
1163
|
+
while (j < afterLines.length &&
|
|
1164
|
+
(i >= beforeLines.length || beforeLines[i] !== afterLines[j])) {
|
|
1165
|
+
hunkAfter.push(afterLines[j++]);
|
|
1166
|
+
}
|
|
1167
|
+
lines.push(`@@ -${start + 1},${hunkBefore.length} +${start + 1},${hunkAfter.length} @@`);
|
|
1168
|
+
hunkBefore.forEach(l => lines.push(`-${l}`));
|
|
1169
|
+
hunkAfter.forEach(l => lines.push(`+${l}`));
|
|
1170
|
+
}
|
|
1171
|
+
return lines.join('\n');
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Compute a proposed diff for a file-mutating tool call without writing to disk.
|
|
1175
|
+
* Returns the unified diff string, or null if it cannot be computed.
|
|
1176
|
+
*/
|
|
1177
|
+
async function computeProposedDiff(toolName, args) {
|
|
1178
|
+
try {
|
|
1179
|
+
const { readFile } = await import('node:fs/promises');
|
|
1180
|
+
const path = args.path;
|
|
1181
|
+
if (!path)
|
|
1182
|
+
return null;
|
|
1183
|
+
const currentContent = await readFile(path, 'utf-8').catch(() => '');
|
|
1184
|
+
let proposed = currentContent;
|
|
1185
|
+
if (toolName === 'edit_file') {
|
|
1186
|
+
proposed = currentContent.replace(args.old_string, args.new_string);
|
|
1187
|
+
}
|
|
1188
|
+
else if (toolName === 'multi_edit') {
|
|
1189
|
+
const edits = args.edits;
|
|
1190
|
+
if (Array.isArray(edits)) {
|
|
1191
|
+
for (const e of edits) {
|
|
1192
|
+
proposed = proposed.replace(e.old_string, e.new_string);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
else if (toolName === 'write_file') {
|
|
1197
|
+
proposed = args.content;
|
|
1198
|
+
}
|
|
1199
|
+
if (proposed === currentContent)
|
|
1200
|
+
return null; // no change
|
|
1201
|
+
return generateUnifiedDiff(path, currentContent, proposed);
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Extract the file path from a tool call's parsed arguments.
|
|
1209
|
+
*
|
|
1210
|
+
* File-editing tools all have a `path` parameter that identifies
|
|
1211
|
+
* the target file. Returns `null` for non-file tools.
|
|
1212
|
+
*/
|
|
1213
|
+
function extractFilePath(toolName, input) {
|
|
1214
|
+
if (!FILE_EDITING_TOOLS.has(toolName)) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
if (input && typeof input === 'object' && 'path' in input) {
|
|
1218
|
+
return input.path;
|
|
1219
|
+
}
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Execute a single tool call.
|
|
1224
|
+
*
|
|
1225
|
+
* Handles:
|
|
1226
|
+
* - Looking up the tool in the registry.
|
|
1227
|
+
* - Parsing the JSON arguments string from the LLM response.
|
|
1228
|
+
* - Validating input against the Zod schema.
|
|
1229
|
+
* - Checking permissions via the caller-supplied callback.
|
|
1230
|
+
* - Invoking the tool and returning the result.
|
|
1231
|
+
* - Notifying start/end callbacks.
|
|
1232
|
+
* - Querying the LSP for diagnostics after file edits.
|
|
1233
|
+
*
|
|
1234
|
+
* @param toolCall - The raw tool call from the LLM response.
|
|
1235
|
+
* @param registry - The tool registry to look up the tool definition.
|
|
1236
|
+
* @param onStart - Optional callback fired before execution.
|
|
1237
|
+
* @param onEnd - Optional callback fired after execution (or error).
|
|
1238
|
+
* @param checkPermission - Optional permission gate.
|
|
1239
|
+
* @param lspManager - Optional LSP manager for post-edit diagnostics.
|
|
1240
|
+
* @returns The tool result (always succeeds; errors are captured inside the result).
|
|
1241
|
+
*/
|
|
1242
|
+
async function executeToolCall(toolCall, registry, onStart, onEnd, checkPermission, lspManager, snapshotManager, sessionId, signal, hookEngine, mode, requestFileDiff, skipRemainingDiffPrompts, rejectRemainingDiffPrompts, onChunk, toolTimeouts, infraContext) {
|
|
1243
|
+
const toolName = toolCall.function.name;
|
|
1244
|
+
// Parse the JSON arguments string from the LLM
|
|
1245
|
+
let parsedArgs;
|
|
1246
|
+
try {
|
|
1247
|
+
parsedArgs = JSON.parse(toolCall.function.arguments);
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
const result = {
|
|
1251
|
+
output: '',
|
|
1252
|
+
error: `Tool '${toolName}' received malformed JSON arguments — please retry the tool call with valid JSON. Received: ${toolCall.function.arguments.slice(0, 200)}`,
|
|
1253
|
+
isError: true,
|
|
1254
|
+
};
|
|
1255
|
+
return result;
|
|
1256
|
+
}
|
|
1257
|
+
const callInfo = {
|
|
1258
|
+
id: toolCall.id,
|
|
1259
|
+
name: toolName,
|
|
1260
|
+
input: parsedArgs,
|
|
1261
|
+
startTime: Date.now(),
|
|
1262
|
+
};
|
|
1263
|
+
// Look up the tool definition
|
|
1264
|
+
const tool = registry.get(toolName);
|
|
1265
|
+
if (!tool) {
|
|
1266
|
+
const result = {
|
|
1267
|
+
output: '',
|
|
1268
|
+
error: `Unknown tool: ${toolName}`,
|
|
1269
|
+
isError: true,
|
|
1270
|
+
};
|
|
1271
|
+
if (onEnd) {
|
|
1272
|
+
onEnd(callInfo, result);
|
|
1273
|
+
}
|
|
1274
|
+
return result;
|
|
1275
|
+
}
|
|
1276
|
+
// Notify start
|
|
1277
|
+
if (onStart) {
|
|
1278
|
+
onStart(callInfo);
|
|
1279
|
+
}
|
|
1280
|
+
// Build shared hook context for PreToolUse and PostToolUse
|
|
1281
|
+
const hookContext = {
|
|
1282
|
+
tool: toolName,
|
|
1283
|
+
input: parsedArgs && typeof parsedArgs === 'object' ? parsedArgs : {},
|
|
1284
|
+
sessionId: sessionId ?? 'default',
|
|
1285
|
+
agent: mode ?? 'build',
|
|
1286
|
+
timestamp: new Date().toISOString(),
|
|
1287
|
+
};
|
|
1288
|
+
// PreToolUse hooks — may block the tool call
|
|
1289
|
+
if (hookEngine) {
|
|
1290
|
+
const preResult = await runPreToolHooks(hookEngine, hookContext);
|
|
1291
|
+
if (!preResult.allowed) {
|
|
1292
|
+
const result = {
|
|
1293
|
+
output: '',
|
|
1294
|
+
error: `Tool '${toolName}' blocked by hook: ${preResult.message ?? 'no reason given'}`,
|
|
1295
|
+
isError: true,
|
|
1296
|
+
};
|
|
1297
|
+
if (onEnd) {
|
|
1298
|
+
onEnd(callInfo, result);
|
|
1299
|
+
}
|
|
1300
|
+
return result;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
// Permission check
|
|
1304
|
+
if (checkPermission) {
|
|
1305
|
+
const decision = await checkPermission(tool, parsedArgs);
|
|
1306
|
+
if (decision === 'deny' || decision === 'block') {
|
|
1307
|
+
const result = {
|
|
1308
|
+
output: '',
|
|
1309
|
+
error: decision === 'block'
|
|
1310
|
+
? `Tool '${toolName}' is blocked by permission policy.`
|
|
1311
|
+
: `User denied permission for tool '${toolName}'.`,
|
|
1312
|
+
isError: true,
|
|
1313
|
+
};
|
|
1314
|
+
if (onEnd) {
|
|
1315
|
+
onEnd(callInfo, result);
|
|
1316
|
+
}
|
|
1317
|
+
return result;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// B1: Pre-approval diff — show proposed change before writing files
|
|
1321
|
+
if (FILE_MUTATING_TOOLS.has(toolName) &&
|
|
1322
|
+
requestFileDiff &&
|
|
1323
|
+
!(skipRemainingDiffPrompts?.value)) {
|
|
1324
|
+
// Auto-reject if 'reject-all' was previously chosen
|
|
1325
|
+
if (rejectRemainingDiffPrompts?.value) {
|
|
1326
|
+
const rejResult = {
|
|
1327
|
+
output: 'User rejected this change (reject-all).',
|
|
1328
|
+
error: undefined,
|
|
1329
|
+
isError: false,
|
|
1330
|
+
};
|
|
1331
|
+
if (onEnd)
|
|
1332
|
+
onEnd(callInfo, rejResult);
|
|
1333
|
+
return rejResult;
|
|
1334
|
+
}
|
|
1335
|
+
const diff = await computeProposedDiff(toolName, parsedArgs);
|
|
1336
|
+
if (diff) {
|
|
1337
|
+
const targetPath = parsedArgs.path ?? '(file)';
|
|
1338
|
+
const decision = await requestFileDiff(targetPath, toolName, diff);
|
|
1339
|
+
if (decision === 'reject') {
|
|
1340
|
+
const rejResult = {
|
|
1341
|
+
output: 'User rejected this change.',
|
|
1342
|
+
error: undefined,
|
|
1343
|
+
isError: false,
|
|
1344
|
+
};
|
|
1345
|
+
if (onEnd)
|
|
1346
|
+
onEnd(callInfo, rejResult);
|
|
1347
|
+
return rejResult;
|
|
1348
|
+
}
|
|
1349
|
+
if (decision === 'reject-all') {
|
|
1350
|
+
if (rejectRemainingDiffPrompts) {
|
|
1351
|
+
rejectRemainingDiffPrompts.value = true;
|
|
1352
|
+
}
|
|
1353
|
+
const rejResult = {
|
|
1354
|
+
output: 'User rejected this change (reject-all).',
|
|
1355
|
+
error: undefined,
|
|
1356
|
+
isError: false,
|
|
1357
|
+
};
|
|
1358
|
+
if (onEnd)
|
|
1359
|
+
onEnd(callInfo, rejResult);
|
|
1360
|
+
return rejResult;
|
|
1361
|
+
}
|
|
1362
|
+
if (decision === 'apply-all' && skipRemainingDiffPrompts) {
|
|
1363
|
+
skipRemainingDiffPrompts.value = true;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
// Capture snapshot before file-modifying tools for undo/redo support
|
|
1368
|
+
if (snapshotManager &&
|
|
1369
|
+
SnapshotManager.shouldSnapshot(toolName, parsedArgs)) {
|
|
1370
|
+
try {
|
|
1371
|
+
await snapshotManager.captureSnapshot({
|
|
1372
|
+
sessionId: sessionId || 'default',
|
|
1373
|
+
messageId: toolCall.id,
|
|
1374
|
+
toolCallId: toolCall.id,
|
|
1375
|
+
description: `${toolName}: ${extractFilePath(toolName, parsedArgs) || '(bash command)'}`,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
// Snapshot failure should never block the tool call
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
// Validate input against the tool's Zod schema and execute
|
|
1383
|
+
let result;
|
|
1384
|
+
try {
|
|
1385
|
+
const validatedInput = tool.inputSchema.parse(parsedArgs);
|
|
1386
|
+
// Thread AbortSignal into bash tool for Ctrl+C child process killing
|
|
1387
|
+
if (signal && toolName === 'bash' && validatedInput && typeof validatedInput === 'object') {
|
|
1388
|
+
validatedInput._signal = signal;
|
|
1389
|
+
}
|
|
1390
|
+
// GAP-20: Build tool execute context, including per-tool timeout from toolTimeouts map
|
|
1391
|
+
// C2: Also pass infraContext from session so tools can use it as fallback
|
|
1392
|
+
const toolCtx = onChunk || toolTimeouts?.[toolName] || infraContext
|
|
1393
|
+
? {
|
|
1394
|
+
...(onChunk ? { onProgress: onChunk } : {}),
|
|
1395
|
+
...(toolTimeouts?.[toolName] !== undefined ? { timeout: toolTimeouts[toolName] } : {}),
|
|
1396
|
+
...(infraContext ? { infraContext } : {}),
|
|
1397
|
+
}
|
|
1398
|
+
: undefined;
|
|
1399
|
+
// C2: Write infra checkpoint before mutating terraform/helm operations
|
|
1400
|
+
if (toolName === 'terraform' || toolName === 'helm') {
|
|
1401
|
+
const _cpArgs = parsedArgs && typeof parsedArgs === 'object'
|
|
1402
|
+
? parsedArgs
|
|
1403
|
+
: {};
|
|
1404
|
+
const _cpAction = String(_cpArgs.action ?? '');
|
|
1405
|
+
const _cpNeedCheckpoint = (toolName === 'terraform' && _cpAction === 'apply') ||
|
|
1406
|
+
(toolName === 'helm' && ['install', 'upgrade', 'rollback'].includes(_cpAction));
|
|
1407
|
+
if (_cpNeedCheckpoint) {
|
|
1408
|
+
writeInfraCheckpoint(toolName, _cpAction, _cpArgs);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
result = await tool.execute(validatedInput, toolCtx);
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
result = {
|
|
1415
|
+
output: '',
|
|
1416
|
+
error: formatToolInputError(toolName, error),
|
|
1417
|
+
isError: true,
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
// -----------------------------------------------------------------------
|
|
1421
|
+
// LSP diagnostics injection
|
|
1422
|
+
// -----------------------------------------------------------------------
|
|
1423
|
+
// After a successful file edit, notify the language server and collect
|
|
1424
|
+
// any diagnostics (type errors, lint issues). If errors exist they are
|
|
1425
|
+
// appended to the tool output so the LLM sees them on its next turn
|
|
1426
|
+
// and can self-correct.
|
|
1427
|
+
if (lspManager && !result.isError) {
|
|
1428
|
+
const filePath = extractFilePath(toolName, parsedArgs);
|
|
1429
|
+
if (filePath) {
|
|
1430
|
+
try {
|
|
1431
|
+
await lspManager.touchFile(filePath);
|
|
1432
|
+
const diagnostics = await lspManager.getDiagnostics(filePath);
|
|
1433
|
+
if (diagnostics.length > 0) {
|
|
1434
|
+
const formatted = lspManager.formatDiagnosticsForAgent(diagnostics);
|
|
1435
|
+
if (formatted) {
|
|
1436
|
+
result = {
|
|
1437
|
+
...result,
|
|
1438
|
+
output: result.output ? `${result.output}\n\n${formatted}` : formatted,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
catch (lspErr) {
|
|
1444
|
+
// LSP errors should never block the agent loop.
|
|
1445
|
+
// Append a note to the tool result so the LLM (and user) can see it.
|
|
1446
|
+
const lspErrMsg = lspErr instanceof Error ? lspErr.message : String(lspErr);
|
|
1447
|
+
result = {
|
|
1448
|
+
...result,
|
|
1449
|
+
output: result.output
|
|
1450
|
+
? `${result.output}\n\n[Note: LSP diagnostics unavailable: ${lspErrMsg}]`
|
|
1451
|
+
: `[Note: LSP diagnostics unavailable: ${lspErrMsg}]`,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
// Gap 12: Mask secrets in tool output before forwarding to callbacks/history
|
|
1457
|
+
if (!result.isError && result.output) {
|
|
1458
|
+
result = { ...result, output: maskSecrets(result.output) };
|
|
1459
|
+
}
|
|
1460
|
+
// PostToolUse hooks — fire-and-forget (audit, auto-format, etc.)
|
|
1461
|
+
if (hookEngine) {
|
|
1462
|
+
await runPostToolHooks(hookEngine, {
|
|
1463
|
+
...hookContext,
|
|
1464
|
+
result: {
|
|
1465
|
+
output: result.isError ? (result.error ?? '') : result.output,
|
|
1466
|
+
isError: result.isError,
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
// Notify end
|
|
1471
|
+
if (onEnd) {
|
|
1472
|
+
onEnd(callInfo, result);
|
|
1473
|
+
}
|
|
1474
|
+
return result;
|
|
1475
|
+
}
|
|
1476
|
+
// ---------------------------------------------------------------------------
|
|
1477
|
+
// Mode-Based Tool Filtering
|
|
1478
|
+
// ---------------------------------------------------------------------------
|
|
1479
|
+
/**
|
|
1480
|
+
* Set of tool names allowed in `plan` mode.
|
|
1481
|
+
*
|
|
1482
|
+
* Plan mode is strictly read-only: the agent can inspect files, search
|
|
1483
|
+
* the codebase, read tasks, estimate costs, and detect drift -- but it
|
|
1484
|
+
* cannot write files, run commands, or mutate infrastructure.
|
|
1485
|
+
*/
|
|
1486
|
+
const PLAN_MODE_TOOLS = new Set([
|
|
1487
|
+
'read_file',
|
|
1488
|
+
'glob',
|
|
1489
|
+
'grep',
|
|
1490
|
+
'list_dir',
|
|
1491
|
+
'webfetch',
|
|
1492
|
+
'todo_read',
|
|
1493
|
+
'todo_write',
|
|
1494
|
+
'task',
|
|
1495
|
+
'cost_estimate',
|
|
1496
|
+
'drift_detect',
|
|
1497
|
+
'cloud_discover',
|
|
1498
|
+
]);
|
|
1499
|
+
/**
|
|
1500
|
+
* Set of tool names blocked in `build` mode.
|
|
1501
|
+
*
|
|
1502
|
+
* Build mode allows reads and writes (file edits, code generation) but
|
|
1503
|
+
* blocks infrastructure-mutating operations that could affect live
|
|
1504
|
+
* environments. The permission engine provides fine-grained control on
|
|
1505
|
+
* top of this coarse filter.
|
|
1506
|
+
*/
|
|
1507
|
+
const BUILD_MODE_BLOCKED_TOOLS = new Set(['terraform', 'kubectl', 'helm']);
|
|
1508
|
+
/**
|
|
1509
|
+
* Filter tools based on the current agent mode.
|
|
1510
|
+
*
|
|
1511
|
+
* - **plan**: Only read-only tools + cost/drift analysis.
|
|
1512
|
+
* - **build**: All tools except infrastructure mutation commands.
|
|
1513
|
+
* - **deploy**: All tools are available.
|
|
1514
|
+
*
|
|
1515
|
+
* @param allTools - Every tool registered in the system.
|
|
1516
|
+
* @param mode - The active agent mode.
|
|
1517
|
+
* @returns The subset of tools available in the given mode.
|
|
1518
|
+
*/
|
|
1519
|
+
export function getToolsForMode(allTools, mode) {
|
|
1520
|
+
switch (mode) {
|
|
1521
|
+
case 'plan':
|
|
1522
|
+
return allTools.filter(t => PLAN_MODE_TOOLS.has(t.name));
|
|
1523
|
+
case 'build':
|
|
1524
|
+
return allTools.filter(t => !BUILD_MODE_BLOCKED_TOOLS.has(t.name));
|
|
1525
|
+
case 'deploy':
|
|
1526
|
+
// All tools available
|
|
1527
|
+
return allTools;
|
|
1528
|
+
default: {
|
|
1529
|
+
// Exhaustive check -- if a new mode is added this becomes a compile
|
|
1530
|
+
// error (assuming AgentMode is a union type).
|
|
1531
|
+
const _exhaustive = mode;
|
|
1532
|
+
return allTools;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|