diff --git a/.claude/skills/kanvas-init-env/SKILL.md b/.claude/skills/kanvas-init-env/SKILL.md new file mode 100644 index 0000000000..7c55f76a76 --- /dev/null +++ b/.claude/skills/kanvas-init-env/SKILL.md @@ -0,0 +1,193 @@ +--- +name: kanvas-init-env +description: > + Initializes a fresh Kanvas local environment: creates a new App, registers an + admin user (which auto-creates the Company and Company Branch), and returns + the API key + auth token needed to start testing. Invoke whenever the user + says "init env", "setup local", "create new app", "bootstrap kanvas", + "new local environment", "spin up kanvas", or wants to start from scratch + on a fresh Kanvas install. +--- + +# kanvas-init-env + +Bootstraps a new Kanvas local environment inside the `phpkanvas-ecosystem` +Docker container. Creates: App → registers Admin User (auto-creates Company + +Branch) → returns credentials. + +**Prerequisites:** `kanvas:setup-ecosystem` must have already been run (all +migrations applied, base seeders done). If not, run the `sync-kanvas-db` +skill first. + +--- + +## Step 1 — Verify the ecosystem is seeded + +Check that the default Kanvas app (id=1) and an owner user (id=1) exist, +since `kanvas:app-create` requires an existing user as owner: + +```bash +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +echo 'Users: ' . \Kanvas\Users\Models\Users::count() . PHP_EOL; +echo 'Apps: ' . \Kanvas\Apps\Models\Apps::count() . PHP_EOL; +" +``` + +If counts are 0, the base seeders haven't run. Run: + +```bash +docker exec phpkanvas-ecosystem php artisan db:seed +``` + +--- + +## Step 2 — Create the App + +Ask the user for (or use these defaults if they say "use defaults"): +- **name** e.g. `"Local Test App"` +- **domain** e.g. `"localhost"` +- **url** e.g. `"http://localhost"` +- **owner email** — the existing user who will own this app (default: the + seeded user, usually `admin@kantvasapp.com` or `anonymous@kanvas.dev`) +- **ecosystem_auth** — `true` (share auth with Kanvas ecosystem) +- **payments_active** — `false` for local testing + +Create via tinker (non-interactive, scriptable): + +```bash +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +use Kanvas\Apps\Actions\CreateAppsAction; +use Kanvas\Apps\DataTransferObject\AppInput; +use Kanvas\Users\Models\Users; + +\$owner = Users::first(); +\$data = AppInput::from([ + 'name' => 'Local Test App', + 'url' => 'http://localhost', + 'description' => 'Local development app', + 'domain' => 'localhost', + 'is_actived' => 1, + 'ecosystem_auth' => 1, + 'payments_active' => 0, + 'is_public' => 1, + 'domain_based' => 0, +]); +\$app = (new CreateAppsAction(\$data, \$owner))->execute(); +echo 'App created!' . PHP_EOL; +echo 'ID: ' . \$app->id . PHP_EOL; +echo 'Key: ' . \$app->key . PHP_EOL; +" 2>/dev/null +``` + +Save the **API Key** from the output — every subsequent request needs it as +the `X-Kanvas-Key` header. + +Alternatively, run the interactive artisan command: + +```bash +docker exec -it phpkanvas-ecosystem php artisan kanvas:app-create +``` + +--- + +## Step 3 — Register the Admin User (auto-creates Company + Branch) + +The `register` mutation creates the user, a default Company, and a default +Company Branch in one call. The first user registered in an app gets the +`Admin` role by default. + +Replace `` with the key from Step 2: + +```bash +curl -s -X POST http://localhost/graphql \ + -H "Content-Type: application/json" \ + -H "X-Kanvas-Key: " \ + -d '{ + "query": "mutation Register($data: RegisterInput!) { register(data: $data) { user { id uuid email default_company } token { token refresh_token token_expires } } }", + "variables": { + "data": { + "firstname": "Admin", + "lastname": "User", + "displayname": "admin", + "email": "admin@localtest.com", + "password": "Test1234!", + "password_confirmation": "Test1234!", + "company_name": "Local Test Company" + } + } + }' | python3 -m json.tool 2>/dev/null || \ +curl -s -X POST http://localhost/graphql \ + -H "Content-Type: application/json" \ + -H "X-Kanvas-Key: " \ + -d '{"query":"mutation { register(data: { firstname: \"Admin\", lastname: \"User\", displayname: \"admin\", email: \"admin@localtest.com\", password: \"Test1234!\", password_confirmation: \"Test1234!\" }) { user { id email } token { token } } }"}' +``` + +Save from the response: +- `user.id` — the admin user ID +- `token.token` — Bearer token for authenticated requests +- `user.default_company` — the auto-created company ID + +--- + +## Step 4 — Verify entities were created + +```bash +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +use Kanvas\Apps\Models\Apps; +use Kanvas\Users\Models\Users; +use Kanvas\Companies\Models\Companies; +use Kanvas\Companies\Models\CompaniesBranches; + +\$app = Apps::latest()->first(); +echo '=== App ===' . PHP_EOL; +echo 'Name: ' . \$app->name . PHP_EOL; +echo 'Key: ' . \$app->key . PHP_EOL; + +\$user = Users::getByEmail('admin@localtest.com'); +echo PHP_EOL . '=== User ===' . PHP_EOL; +echo 'ID: ' . \$user->id . PHP_EOL; +echo 'Email: ' . \$user->email . PHP_EOL; + +\$company = Companies::find(\$user->default_company); +echo PHP_EOL . '=== Company ===' . PHP_EOL; +echo 'Name: ' . \$company->name . PHP_EOL; + +\$branch = CompaniesBranches::where('companies_id', \$company->id)->first(); +echo PHP_EOL . '=== Branch ===' . PHP_EOL; +echo 'Name: ' . \$branch->name . PHP_EOL; +" 2>/dev/null +``` + +--- + +## Step 5 — Report credentials to the user + +Present a summary block they can copy into `.env` or pass to the admin UI: + +``` +Environment ready +───────────────────────────────────────── +App Name: Local Test App +App Key: ← X-Kanvas-Key header / NEXT_PUBLIC_KANVAS_KEY +───────────────────────────────────────── +Admin Email: admin@localtest.com +Admin Password: Test1234! +Bearer Token: ← Authorization: Bearer +───────────────────────────────────────── +Company: Local Test Company (id: N) +Branch: Default Branch (id: N) +───────────────────────────────────────── +``` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `No query results for model [Users]` on app create | No users seeded yet | Run `docker exec phpkanvas-ecosystem php artisan db:seed` first | +| `Email has already been taken` | User registered in this app already | Change the email or use a different app key | +| `422 / validation error` on register | `displayname` already taken globally | Change the displayname | +| `Role not found` | Roles not created | Run `docker exec phpkanvas-ecosystem php artisan kanvas:create-role Admin` | +| GraphQL returns `Unauthenticated` | Wrong or missing `X-Kanvas-Key` | Double-check the key from Step 2 | +| `curl: connection refused` | API not running | Make sure Docker containers are up: `docker compose up -d` | diff --git a/.claude/skills/sync-intelligence-db/SKILL.md b/.claude/skills/sync-intelligence-db/SKILL.md new file mode 100644 index 0000000000..09f687e931 --- /dev/null +++ b/.claude/skills/sync-intelligence-db/SKILL.md @@ -0,0 +1,140 @@ +--- +name: sync-kanvas-db +description: > + Runs pending database migrations across ALL Kanvas Laravel API databases on + the local Docker environment. Invoke whenever the user says "sync db", + "run migrations", "sync database", "pull from dev", "run pending migrations", + or when any page returns a 500/GraphQL error caused by a missing table or + column. Also invoke proactively after any `git pull` or `git merge` from + dev/main when migration files have changed. +--- + +# sync-kanvas-db + +Applies pending migrations to every database inside the `phpkanvas-ecosystem` +Docker container. Kanvas uses multiple separate MySQL databases, each with its +own migration subfolder and Laravel connection name. + +## Container and paths + +- **Container**: `phpkanvas-ecosystem` +- **API repo (host)**: `/Users/mcwhite/Documents/docker_projects/kanvas/kanvas-ecosystem-api` +- **Migrations root (inside container)**: `database/migrations/` + +## Database map + +Each subfolder uses a different Laravel connection. Run migrations for the +subfolder with the matching `--database` flag. + +| Migration path | `--database` flag | Notes | +|---|---|---| +| `database/migrations/` (root) | *(omit — default mysql)* | Core platform tables | +| `database/migrations/ActionEngine/` | `--database=action_engine` | | +| `database/migrations/Event/` | `--database=event` | | +| `database/migrations/Guild/` | `--database=crm` | CRM module | +| `database/migrations/Intelligence/` | `--database=intelligence` | AI agents, tools, deployments | +| `database/migrations/Inventory/` | `--database=inventory` | | +| `database/migrations/Social/` | `--database=social` | | +| `database/migrations/Souk/` | `--database=commerce` | Commerce/orders | +| `database/migrations/Subscription/` | *(omit — default mysql)* | Subscription tables in main DB | +| `database/migrations/Workflow/` | `--database=workflow` | | + +## Step 1 — Find pending migrations + +Run this for each path you care about (or all of them after a big pull): + +```bash +# For a specific path: +docker exec phpkanvas-ecosystem php artisan migrate:status \ + --path=database/migrations/Intelligence --database=intelligence 2>/dev/null \ + | grep "Pending" + +# For the default-connection paths (root, ActionEngine, Subscription): +docker exec phpkanvas-ecosystem php artisan migrate:status \ + --path=database/migrations 2>/dev/null | grep "Pending" +``` + +Filter results to only entries under the path you queried — artisan sometimes +shows migrations from the whole project. + +## Step 2 — Run each pending migration individually + +Run in chronological order (filename = timestamp order), one at a time. + +```bash +docker exec phpkanvas-ecosystem php artisan migrate \ + --path= \ + [--database=] \ + --force 2>&1 +``` + +### Handling failures gracefully + +These errors mean the schema already diverged on your local DB — skip, don't +abort: + +| Error | Action | +|---|---| +| `Column already exists` (1060) | Schema already present. Fake-mark (Step 3) and continue. | +| `Table already exists` (1050) | Table already present. Fake-mark (Step 3) and continue. | +| `Duplicate key name` (1061) | Index already exists. Fake-mark (Step 3) and continue. | +| `Field 'uuid' doesn't have a default value` (1364) | Seed migration omits `uuid`. Fix: add `'uuid' => \Illuminate\Support\Str::uuid()->toString()` to each insert in the migration file, then re-run. | +| `Column not found` (1054) after `->after(...)` | The referenced column doesn't exist locally. Fix: remove the `->after('...')` call in the migration file, then re-run. | +| Any other error | Log it and continue to the next migration. | + +When editing a migration file to fix a local divergence, make the change +minimally (remove `->after()`, add `uuid`, wrap in `hasColumn()` guard). Don't +commit these fixups unless asked. + +## Step 3 — Fake-mark already-applied migrations + +If the schema change already exists but the migration record doesn't, insert a +fake record so Laravel stops reporting it as Pending. + +**IMPORTANT**: you must insert into the correct DB's `migrations` table — match +the `--database` connection from the table above. + +```bash +# Example for intelligence DB: +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +DB::connection('intelligence')->table('migrations')->insertOrIgnore([ + 'migration' => '2026_05_15_000000_migration_name_without_php', + 'batch' => 99 +]); +" + +# Example for default mysql DB: +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +DB::table('migrations')->insertOrIgnore([ + 'migration' => '2026_05_15_000000_migration_name_without_php', + 'batch' => 99 +]); +" +``` + +Use only when the schema change definitely exists but the migration keeps +showing as Pending. + +## Step 4 — Report + +After processing all paths: + +``` +Kanvas DB sync complete +✓ Ran: +⚠ Skipped: +✗ Failed: +``` + +If nothing was pending, say so and exit cleanly. + +## Common missing tables (reference) + +These are frequently missing on local DBs that haven't been fully migrated: + +| Table | DB | Migration file | +|---|---|---| +| `agents_kanvas_modules` | `intelligence` | `2026_05_15_040000_create_agents_kanvas_modules.php` | +| `agent_daily_cycles` | `intelligence` | `2026_05_15_080000_create_agent_daily_cycles.php` | +| `agent_sleep_phases` | `intelligence` | `2026_05_15_090000_create_agent_sleep_phases.php` | +| `nervous_system_tool_categories` | `intelligence` | `2026_05_15_120000_create_nervous_system_tool_categories.php` | diff --git a/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php b/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php index 3d807469a4..3fecd76172 100644 --- a/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php +++ b/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php @@ -34,6 +34,12 @@ class CollectDeploymentUsageCommand extends Command public function handle(): void { + if (config('otel.enabled', false)) { + $this->info('OTel is active — token usage is collected via the collector pipeline. Skipping SSH poll.'); + + return; + } + /** @var Apps $app */ $app = Apps::getById((int) $this->argument('app_id')); $this->overwriteAppService($app); diff --git a/app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php b/app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php new file mode 100644 index 0000000000..45bb4f9495 --- /dev/null +++ b/app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php @@ -0,0 +1,106 @@ +info('OTel is active — token usage is collected via the collector pipeline. Skipping SSH poll.'); + + return self::SUCCESS; + } + + $specificId = $this->option('deployment'); + $dryRun = (bool) $this->option('dry-run'); + + $query = AgentDeployment::where('status', 'running') + ->where('is_deleted', 0) + ->with(['machine', 'agent']); + + if ($specificId !== null) { + $query->where('id', (int) $specificId); + } + + $deployments = $query->get(); + + if ($deployments->isEmpty()) { + $this->info('No running deployments found.'); + + return self::SUCCESS; + } + + $this->info("Found {$deployments->count()} running deployment(s). Collecting usage..."); + + $succeeded = 0; + $failed = 0; + + foreach ($deployments as $deployment) { + /** @var AgentDeployment $deployment */ + $label = "deployment #{$deployment->getId()} (container: {$deployment->container_name}, app: {$deployment->apps_id})"; + + if ($dryRun) { + $this->line(" [dry-run] Would collect: {$label}"); + $succeeded++; + + continue; + } + + try { + $app = Apps::getById($deployment->apps_id); + $company = Companies::getById($deployment->companies_id); + $provider = AgentRuntimeProviderFactory::forDeployment($deployment); + + $snapshot = $provider->collectUsage($deployment, $app, $company); + + $this->info( + " ✓ {$label}" + . " → snapshot #{$snapshot->getId()}" + . " | total_tokens={$snapshot->total_tokens}" + . " | in={$snapshot->input_tokens} out={$snapshot->output_tokens}" + ); + + $succeeded++; + } catch (Throwable $e) { + $this->error(" ✗ {$label}: {$e->getMessage()}"); + $failed++; + } + } + + $this->newLine(); + $this->info("Done — succeeded: {$succeeded}, failed: {$failed}"); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 91b7405216..40920018d4 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,6 +8,7 @@ use App\Console\Commands\Connectors\OpenClaw\CollectAgentTelemetryCommand; use App\Console\Commands\Ecosystem\Users\DeleteUsersRequestedCommand; use App\Console\Commands\ImportPromptsFromDocsCommand; +use App\Console\Commands\Intelligence\CollectAllDeploymentsUsageCommand; use App\Console\Commands\Lead\Schedules\LeadFollowUpSchedule; use App\Console\Commands\NervousSystem\Schedules\NervousSystemSchedule; use App\Console\Commands\Social\ScoutMessageReindexCommand; @@ -55,10 +56,17 @@ protected function schedule(Schedule $schedule) // rationale live in LeadFollowUpSchedule. LeadFollowUpSchedule::register($schedule); - /* $schedule->command(CollectAgentTelemetryCommand::class) - ->everyMinute() - ->withoutOverlapping(5) - ->runInBackground(); */ + // OpenClaw runtime telemetry — health, gateway, sessions, events (every minute). + $schedule->command(CollectAgentTelemetryCommand::class) + ->everyMinute() + ->withoutOverlapping(5) + ->runInBackground(); + + // Token usage snapshots — input/output/cache tokens per deployment (hourly). + $schedule->command(CollectAllDeploymentsUsageCommand::class) + ->hourly() + ->withoutOverlapping(10) + ->runInBackground(); //$schedule->command(SendBookingRemindersCommand::class)->everyFiveMinutes(); #$schedule->command(ScoutMessageReindexCommand::class, [env('MESSAGE_REINDEX_SCOUT_APP_ID', '13'), env('MESSAGE_REINDEX_SCOUT_MESSAGE_TYPES_ID', '572')])->everyTenMinutes(); #$schedule->command(MailunregisteredUsersCampaignCommand::class)->weeklyOn(2, '2:30'); //@todo move this to normal cron diff --git a/app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php b/app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php new file mode 100644 index 0000000000..959188c3a0 --- /dev/null +++ b/app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php @@ -0,0 +1,22 @@ + $payload */ + $payload = $args['payload'] ?? []; + + if (empty($payload)) { + return true; + } + + return app(OtlpUsageIngestionService::class)->ingest($payload); + } +} diff --git a/app/Http/Controllers/Intelligence/OtlpAdapterController.php b/app/Http/Controllers/Intelligence/OtlpAdapterController.php new file mode 100644 index 0000000000..4647e9fd29 --- /dev/null +++ b/app/Http/Controllers/Intelligence/OtlpAdapterController.php @@ -0,0 +1,46 @@ +header('X-Internal-Token') !== $expectedToken) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $payload = $request->json()->all(); + + if (empty($payload)) { + // Return OTLP partial-success with no data — don't error on empty flushes + return response()->json(['partialSuccess' => new \stdClass()]); + } + + $ingestionService->ingest($payload); + + // OTLP spec: return 200 with partialSuccess object (empty = full success) + return response()->json(['partialSuccess' => new \stdClass()]); + } +} diff --git a/config/otel.php b/config/otel.php new file mode 100644 index 0000000000..98f55dafb0 --- /dev/null +++ b/config/otel.php @@ -0,0 +1,29 @@ +_otel'). + | + */ + + 'enabled' => (bool) env('OTEL_ENABLED', false), + + // Endpoint the agent containers push spans to (must be reachable from the container network) + 'collector_endpoint' => env('OTEL_COLLECTOR_ENDPOINT', 'http://otel-collector:4317'), + + // Shared secret validated by the OTLP adapter endpoint. + // Set the same value in OTEL_INTERNAL_TOKEN in both the collector config and .env + 'internal_token' => env('OTEL_INTERNAL_TOKEN', ''), +]; diff --git a/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php b/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php index 759d38c9dd..b1d0faa46e 100644 --- a/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php +++ b/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php @@ -48,6 +48,7 @@ public function up(): void continue; } DB::connection('intelligence')->table('nervous_system_tool_categories')->insert([ + 'uuid' => \Illuminate\Support\Str::uuid()->toString(), 'apps_id' => 0, 'slug' => $category['slug'], 'name' => $category['name'], diff --git a/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php b/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php index e48fbe899e..297871ad79 100644 --- a/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php +++ b/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php @@ -12,10 +12,12 @@ public function up(): void { - Schema::table('nervous_system_tools', function (Blueprint $table) { - $table->unsignedBigInteger('tool_category_id')->nullable()->after('category'); - $table->index('tool_category_id', 'idx_tools_category_id'); - }); + if (! Schema::connection('intelligence')->hasColumn('nervous_system_tools', 'tool_category_id')) { + Schema::table('nervous_system_tools', function (Blueprint $table) { + $table->unsignedBigInteger('tool_category_id')->nullable()->after('category'); + $table->index('tool_category_id', 'idx_tools_category_id'); + }); + } // Backfill: map the existing enum string → category row id. Platform // categories live on apps_id=0, so the lookup ignores apps_id. @@ -32,10 +34,12 @@ public function up(): void ->update(['tool_category_id' => $id]); } - Schema::table('nervous_system_tools', function (Blueprint $table) { - $table->dropIndex('idx_tools_category'); - $table->dropColumn('category'); - }); + if (Schema::connection('intelligence')->hasColumn('nervous_system_tools', 'category')) { + Schema::table('nervous_system_tools', function (Blueprint $table) { + $table->dropIndex('idx_tools_category'); + $table->dropColumn('category'); + }); + } } public function down(): void diff --git a/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php b/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php index 1d7218a17a..40901e1e32 100644 --- a/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php +++ b/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php @@ -11,14 +11,18 @@ public function up(): void { - Schema::table('agents', function (Blueprint $table) { - $table->boolean('is_sub_agent')->default(false)->after('is_active'); - }); + if (! Schema::connection('intelligence')->hasColumn('agents', 'is_sub_agent')) { + Schema::table('agents', function (Blueprint $table) { + $table->boolean('is_sub_agent')->default(false)->after('is_active'); + }); + } - Schema::table('nervous_system_tools', function (Blueprint $table) { - $table->unsignedBigInteger('agents_id')->nullable()->after('tool_category_id'); - $table->foreign('agents_id')->references('id')->on('agents')->nullOnDelete(); - }); + if (! Schema::connection('intelligence')->hasColumn('nervous_system_tools', 'agents_id')) { + Schema::table('nervous_system_tools', function (Blueprint $table) { + $table->unsignedBigInteger('agents_id')->nullable(); + $table->foreign('agents_id')->references('id')->on('agents')->nullOnDelete(); + }); + } } public function down(): void diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 5345110161..c1e97c14e1 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -193,6 +193,23 @@ services: networks: - sail + otel-collector: + image: otel/opentelemetry-collector-contrib:0.102.0 + container_name: otel-collector + command: ["--config=/etc/otel/config.yaml"] + environment: + OTEL_INTERNAL_TOKEN: "${OTEL_INTERNAL_TOKEN}" + volumes: + - ./docker/otel/collector.yaml:/etc/otel/config.yaml:ro + ports: + - "4317:4317" + - "4318:4318" + networks: + - sail + depends_on: + - php + restart: unless-stopped + nginx: image: nginx:latest container_name: nginx${APP_CONTAINER_NAME} diff --git a/docker-compose.yml b/docker-compose.yml index 94cafb816e..abe6c58f71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -244,6 +244,23 @@ services: networks: - sail + otel-collector: + image: otel/opentelemetry-collector-contrib:0.102.0 + container_name: otel-collector + command: ["--config=/etc/otel/config.yaml"] + environment: + OTEL_INTERNAL_TOKEN: "${OTEL_INTERNAL_TOKEN}" + volumes: + - ./docker/otel/collector.yaml:/etc/otel/config.yaml:ro + ports: + - "4317:4317" + - "4318:4318" + networks: + - sail + depends_on: + - php + restart: unless-stopped + puppeteer: image: browserless/chrome container_name: puppeteer${APP_CONTAINER_NAME} diff --git a/docker/otel/collector.yaml b/docker/otel/collector.yaml new file mode 100644 index 0000000000..23d4bf75c1 --- /dev/null +++ b/docker/otel/collector.yaml @@ -0,0 +1,40 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 10s + filter/genai: + error_mode: ignore + spans: + include: + match_type: regexp + attributes: + - key: gen_ai.system + value: ".*" + +exporters: + otlphttp/kanvas: + # traces_endpoint is used as-is (no suffix appended), pointing directly at + # our adapter route. The adapter parses OTLP traces JSON and extracts + # gen_ai.usage.* attributes to upsert into agent_usage_snapshots. + traces_endpoint: http://php:8000/v1/telemetry/otlp + tls: + insecure: true + headers: + X-Internal-Token: "${env:OTEL_INTERNAL_TOKEN}" + # Uncomment for debugging — prints received spans to container logs + # debug: + # verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + processors: [filter/genai, batch] + exporters: [otlphttp/kanvas] diff --git a/docker/otel/otel-init.js b/docker/otel/otel-init.js new file mode 100644 index 0000000000..c8eeb4136a --- /dev/null +++ b/docker/otel/otel-init.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * OpenTelemetry bootstrap — required via NODE_OPTIONS=--require /opt/otel/init.js + * + * Auto-instruments Anthropic / OpenAI LLM calls and exports spans to the + * OTel Collector over gRPC. The spanmetrics connector in the collector + * aggregates gen_ai.usage.* attributes into metrics and forwards them to + * the Kanvas API for storage in agent_usage_snapshots. + * + * Resource attributes set here are attached to every span: + * agent.deployment_id — from KANVAS_DEPLOYMENT_ID (always set by the deploy action) + * agent.container_name — from HOSTNAME (Docker sets this to the container ID / name) + * service.name — from OTEL_SERVICE_NAME (set to "openclaw" or "hermes") + * + * Packages are installed at /opt/otel/node_modules/ during the image build + * (see the {{OTEL_LAYER}} section in the Dockerfile template). + * Absolute requires keep this script isolated from the app's own node_modules. + */ + +// Bail out silently if the collector endpoint is not configured — this prevents +// the script from crashing agents that were deployed before OTel was enabled. +if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) { + return; +} + +try { + const { NodeSDK } = require('/opt/otel/node_modules/@opentelemetry/sdk-node'); + const { OTLPTraceExporter } = require('/opt/otel/node_modules/@opentelemetry/exporter-trace-otlp-grpc'); + const { Resource } = require('/opt/otel/node_modules/@opentelemetry/resources'); + const { AnthropicInstrumentation } = require('/opt/otel/node_modules/@traceloop/instrumentation-anthropic'); + + const sdk = new NodeSDK({ + resource: new Resource({ + 'service.name': process.env.OTEL_SERVICE_NAME || 'agent-runtime', + 'agent.deployment_id': process.env.KANVAS_DEPLOYMENT_ID || '', + 'agent.container_name': process.env.HOSTNAME || '', + }), + traceExporter: new OTLPTraceExporter({ + url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + }), + instrumentations: [ + new AnthropicInstrumentation(), + ], + }); + + sdk.start(); + + process.on('SIGTERM', () => { + sdk.shutdown().catch(() => {}); + }); +} catch (err) { + // Never crash the agent runtime if OTel fails to start + process.stderr.write('[otel-init] Failed to start: ' + (err && err.message ? err.message : String(err)) + '\n'); +} diff --git a/graphql/schemas/Intelligence/agent.graphql b/graphql/schemas/Intelligence/agent.graphql index 91399f5b56..efe2ec0956 100644 --- a/graphql/schemas/Intelligence/agent.graphql +++ b/graphql/schemas/Intelligence/agent.graphql @@ -22,6 +22,7 @@ type AgentAi { tools_config: String deployment_status: String! activeDeployment: AgentDeploymentType @hasOne + latestDeployment: AgentDeploymentType @hasOne agent_model_id: ID parent_id: ID is_active: Boolean! diff --git a/graphql/schemas/Intelligence/agentRuntime.graphql b/graphql/schemas/Intelligence/agentRuntime.graphql index f0d5c7d5a6..04056bdd40 100644 --- a/graphql/schemas/Intelligence/agentRuntime.graphql +++ b/graphql/schemas/Intelligence/agentRuntime.graphql @@ -59,6 +59,12 @@ extend type Subscription { } extend type Mutation @guard { + "Ingest an OTLP metrics payload from the OTel Collector and persist token usage to agent_usage_snapshots." + ingestAgentTelemetry(payload: Mixed!): Boolean! + @field( + resolver: "App\\GraphQL\\Intelligence\\Mutations\\AgentRuntime\\IngestOtlpMetricsMutation" + ) + agentRuntimeCreateMachine(input: AgentMachineInput!): AgentMachineType! @field( resolver: "App\\GraphQL\\Intelligence\\Mutations\\AgentRuntime\\AgentMachineMutation@create" diff --git a/routes/api.php b/routes/api.php index 5d150f1ab9..2bf48f5605 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,10 @@ use Spatie\Health\Http\Controllers\SimpleHealthCheckController; Route::get('/', [\App\Http\Controllers\IndexController::class, 'index']); + +// OTel Collector → GraphQL bridge (Docker-internal only, validated by X-Internal-Token header) +Route::post('/telemetry/otlp', [\App\Http\Controllers\Intelligence\OtlpAdapterController::class, 'handle']) + ->middleware('throttle:600,1'); Route::post('/receiver/{uuid}', [\App\Http\Controllers\ReceiverController::class, 'store']); Route::get('/oauth/{uuid}', [\App\Http\Controllers\OAuthIntegrationController::class, 'auth']); Route::get('/oauth/{uuid}/callback', [\App\Http\Controllers\OAuthIntegrationController::class, 'callback']); diff --git a/src/Domains/Connectors/Hermes/Templates/Dockerfile b/src/Domains/Connectors/Hermes/Templates/Dockerfile index 587d3d9450..6bd6b253bd 100644 --- a/src/Domains/Connectors/Hermes/Templates/Dockerfile +++ b/src/Domains/Connectors/Hermes/Templates/Dockerfile @@ -1 +1,3 @@ FROM {{BASE_IMAGE}} + +{{OTEL_LAYER}} \ No newline at end of file diff --git a/src/Domains/Connectors/OpenClaw/Templates/Dockerfile b/src/Domains/Connectors/OpenClaw/Templates/Dockerfile index 86fa9ca798..16819c691d 100644 --- a/src/Domains/Connectors/OpenClaw/Templates/Dockerfile +++ b/src/Domains/Connectors/OpenClaw/Templates/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update \ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh +{{OTEL_LAYER}} USER node ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php b/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php index 0008c55e30..5a1687a77f 100644 --- a/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php +++ b/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php @@ -74,7 +74,7 @@ public function execute(): AgentUsageSnapshot ); } - protected static function inferLlmProvider(string $model): ?string + public static function inferLlmProvider(string $model): ?string { if ($model === '') { return null; diff --git a/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php b/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php index 543ae3cff2..1e81bc0591 100644 --- a/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php +++ b/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php @@ -127,6 +127,12 @@ private function ensureSharedImage(SshClient $client): void $client->writeFileAsUser($imageDir . '/entrypoint.sh', $builder->buildEntrypoint(), 'root'); $client->exec('sudo chmod +x ' . escapeshellarg($imageDir . '/entrypoint.sh')); + // When OTel is enabled, the Dockerfile includes a COPY otel-init.js layer. + // The file must be present in the build context before `docker build` runs. + if (config('otel.enabled', false)) { + $client->writeFileAsUser($imageDir . '/otel-init.js', $builder->buildOtelInitScript(), 'root'); + } + $result = $client->exec( 'cd ' . escapeshellarg($imageDir) . ' && sudo docker build -t ' . escapeshellarg($imageName) . ' . 2>&1; echo "EXIT_CODE:$?"', 900, diff --git a/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php b/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php index 4415203262..38c2744dd8 100644 --- a/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php +++ b/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php @@ -104,9 +104,52 @@ public function buildDockerfile(AppInterface $app): string $raw = (string) file_get_contents(static::getTemplatesDir() . '/Dockerfile'); - // Substitute `{{BASE_IMAGE}}` if the template uses it (pinned providers); raw passthrough - // otherwise (providers that haven't adopted the placeholder yet). - return rtrim(str_replace('{{BASE_IMAGE}}', $this->getBaseImage($app), $raw)); + // Substitute `{{BASE_IMAGE}}` and `{{OTEL_LAYER}}`. The OTel layer installs the + // Node.js OTel packages at /opt/otel so otel-init.js can use absolute requires — + // keeping the instrumentation fully isolated from the app's own node_modules. + // The layer is only emitted when otel.enabled=true; otherwise the placeholder is + // replaced with an empty string so the Dockerfile stays valid. + $otelLayer = config('otel.enabled', false) ? $this->buildOtelDockerfileLayer() : ''; + + return rtrim(str_replace( + ['{{BASE_IMAGE}}', '{{OTEL_LAYER}}'], + [$this->getBaseImage($app), $otelLayer], + $raw, + )); + } + + /** + * Dockerfile RUN layer that installs the OTel Node.js packages at /opt/otel and + * copies otel-init.js there. The file must already exist in the Docker build context + * (written by BaseLaunchAgentOnMachineAction::ensureSharedImage when otel.enabled). + */ + protected function buildOtelDockerfileLayer(): string + { + return implode("\n", [ + '# OTel token-usage instrumentation (added when otel.enabled=true)', + 'USER root', + 'RUN mkdir -p /opt/otel', + 'COPY otel-init.js /opt/otel/init.js', + 'RUN cd /opt/otel \\', + ' && npm init -y \\', + ' && npm install --save \\', + ' @opentelemetry/sdk-node \\', + ' @opentelemetry/exporter-trace-otlp-grpc \\', + ' @opentelemetry/resources \\', + ' @traceloop/instrumentation-anthropic', + ]); + } + + /** + * Content of otel-init.js that is written to the Docker build context and later + * installed at /opt/otel/init.js inside the image. Read from the canonical source + * in docker/otel/ so the template and init script are always in sync. + */ + public function buildOtelInitScript(): string + { + $path = base_path('docker/otel/otel-init.js'); + + return file_exists($path) ? (string) file_get_contents($path) : ''; } /** @@ -235,6 +278,18 @@ public function buildDockerCompose( $envVars['TELEGRAM_ALLOWED_USERS'] = (string) $telegramAllowedUsers; } + // OTel env vars — injected when otel.enabled is true so the container's + // otel-init.js can export spans to the collector on the same Docker network. + // KANVAS_DEPLOYMENT_ID is already set above and reused by otel-init.js as + // the agent.deployment_id resource attribute. + // NODE_OPTIONS bootstraps the SDK before any app code runs (--require). + if (config('otel.enabled', false)) { + $envVars['OTEL_EXPORTER_OTLP_ENDPOINT'] = config('otel.collector_endpoint', 'http://otel-collector:4317'); + $envVars['OTEL_SERVICE_NAME'] = $envVars['OTEL_SERVICE_NAME'] ?? $this->getProviderConfig()->providerName; + $envVars['NODE_OPTIONS'] = ($envVars['NODE_OPTIONS'] ?? '') . ' --require /opt/otel/init.js'; + $envVars['NODE_OPTIONS'] = ltrim($envVars['NODE_OPTIONS']); + } + $envLines = ''; foreach ($envVars as $key => $value) { $envLines .= " - {$key}={$value}\n"; diff --git a/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php b/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php new file mode 100644 index 0000000000..c5a94420cb --- /dev/null +++ b/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php @@ -0,0 +1,329 @@ +ingestTraces($resourceSpans); + } + + // Legacy metrics path — kept for backward compat with direct API calls + $resourceMetrics = $payload['resourceMetrics'] ?? []; + + if (! empty($resourceMetrics)) { + return $this->ingestMetrics($resourceMetrics); + } + + return true; + } + + /** + * Parse OTLP traces format: resourceSpans[].scopeSpans[].spans[].attributes + * + * @param array> $resourceSpans + */ + private function ingestTraces(array $resourceSpans): bool + { + $succeeded = 0; + $failed = 0; + + // Aggregate tokens per deployment across all spans in this batch + /** @var array}> $byDeployment */ + $byDeployment = []; + + foreach ($resourceSpans as $rs) { + $resourceAttrs = $this->extractAttributes($rs['resource']['attributes'] ?? []); + $deploymentKey = $resourceAttrs['agent.deployment_id'] ?? $resourceAttrs['agent.container_name'] ?? null; + + if ($deploymentKey === null) { + continue; + } + + if (! isset($byDeployment[$deploymentKey])) { + $byDeployment[$deploymentKey] = [ + 'deployment_id' => $resourceAttrs['agent.deployment_id'] ?? null, + 'container_name' => $resourceAttrs['agent.container_name'] ?? null, + 'model' => null, + 'system' => null, + 'tokens' => ['input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cache_read_tokens' => 0, 'cache_write_tokens' => 0], + ]; + } + + foreach ($rs['scopeSpans'] ?? [] as $scope) { + foreach ($scope['spans'] ?? [] as $span) { + $spanAttrs = $this->extractAttributes($span['attributes'] ?? []); + + $byDeployment[$deploymentKey]['model'] ??= $spanAttrs['gen_ai.request.model'] ?? null; + $byDeployment[$deploymentKey]['system'] ??= $spanAttrs['gen_ai.system'] ?? null; + + $t = &$byDeployment[$deploymentKey]['tokens']; + $t['input_tokens'] += (int) ($spanAttrs['gen_ai.usage.input_tokens'] ?? 0); + $t['output_tokens'] += (int) ($spanAttrs['gen_ai.usage.output_tokens'] ?? 0); + $t['total_tokens'] += (int) ($spanAttrs['gen_ai.usage.total_tokens'] ?? 0); + $t['cache_read_tokens'] += (int) ($spanAttrs['gen_ai.usage.cache_read_tokens'] ?? $spanAttrs['gen_ai.usage.cache.read_input_tokens'] ?? 0); + $t['cache_write_tokens'] += (int) ($spanAttrs['gen_ai.usage.cache_write_tokens'] ?? $spanAttrs['gen_ai.usage.cache.creation_input_tokens'] ?? 0); + unset($t); + } + } + } + + foreach ($byDeployment as $bucket) { + try { + $this->upsertUsageSnapshot( + $bucket['deployment_id'], + $bucket['container_name'], + $bucket['model'], + $bucket['system'], + $bucket['tokens'], + ); + $succeeded++; + } catch (Throwable $e) { + $failed++; + Log::warning('OtlpUsageIngestionService: failed to upsert snapshot — ' . $e->getMessage(), [ + 'deployment_id' => $bucket['deployment_id'], + 'container_name' => $bucket['container_name'], + ]); + } + } + + if ($failed > 0) { + Log::warning("OtlpUsageIngestionService: {$failed} deployment(s) failed, {$succeeded} succeeded."); + } + + return $failed === 0; + } + + /** + * Legacy OTLP metrics format: resourceMetrics[].scopeMetrics[].metrics[] + * + * @param array> $resourceMetrics + */ + private function ingestMetrics(array $resourceMetrics): bool + { + $succeeded = 0; + $failed = 0; + + foreach ($resourceMetrics as $rm) { + try { + $attrs = $this->extractAttributes($rm['resource']['attributes'] ?? []); + $tokens = ['input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cache_read_tokens' => 0, 'cache_write_tokens' => 0]; + $model = $attrs['gen_ai.request.model'] ?? null; + $system = $attrs['gen_ai.system'] ?? null; + + foreach ($rm['scopeMetrics'] ?? [] as $scope) { + foreach ($scope['metrics'] ?? [] as $metric) { + $name = $metric['name'] ?? ''; + $value = $this->extractFirstDataPointValue($metric); + + match ($name) { + 'gen_ai.usage.input_tokens' => $tokens['input_tokens'] += $value, + 'gen_ai.usage.output_tokens' => $tokens['output_tokens'] += $value, + 'gen_ai.usage.total_tokens' => $tokens['total_tokens'] += $value, + 'gen_ai.usage.cache_read_tokens' => $tokens['cache_read_tokens'] += $value, + 'gen_ai.usage.cache_write_tokens' => $tokens['cache_write_tokens'] += $value, + default => null, + }; + + if ($model === null || $system === null) { + $dp0Attrs = $this->extractAttributes($metric['gauge']['dataPoints'][0]['attributes'] ?? []); + $model ??= $dp0Attrs['gen_ai.request.model'] ?? null; + $system ??= $dp0Attrs['gen_ai.system'] ?? null; + } + } + } + + $this->upsertUsageSnapshot($attrs['agent.deployment_id'] ?? null, $attrs['agent.container_name'] ?? null, $model, $system, $tokens); + $succeeded++; + } catch (Throwable $e) { + $failed++; + Log::warning('OtlpUsageIngestionService: failed to process resource metrics — ' . $e->getMessage(), [ + 'resource' => $rm['resource'] ?? null, + ]); + } + } + + if ($failed > 0) { + Log::warning("OtlpUsageIngestionService: {$failed} resource(s) failed, {$succeeded} succeeded."); + } + + return $failed === 0; + } + + /** + * Resolve deployment and upsert AgentUsageSnapshot. + * + * @param array $tokens + */ + private function upsertUsageSnapshot(?string $deploymentId, ?string $containerName, ?string $model, ?string $system, array $tokens): void + { + if (empty($deploymentId) && empty($containerName)) { + return; + } + + $deployment = $this->resolveDeployment($deploymentId, $containerName); + + if ($deployment === null) { + Log::debug("OtlpUsageIngestionService: no deployment found for id={$deploymentId} container={$containerName}"); + + return; + } + + // Compute total if not emitted separately + if ($tokens['total_tokens'] === 0 && ($tokens['input_tokens'] + $tokens['output_tokens']) > 0) { + $tokens['total_tokens'] = $tokens['input_tokens'] + $tokens['output_tokens']; + } + + if ($tokens['total_tokens'] === 0) { + return; + } + + $provider = $deployment->provider + ?? ($system ?? null) + ?? ($model !== null ? BaseCollectDeploymentUsageAction::inferLlmProvider($model) : null); + + $app = Apps::getById($deployment->apps_id); + $company = Companies::getById($deployment->companies_id); + + $source = ($provider !== null ? strtolower($provider) : 'agent') . '_otel'; + + AgentUsageSnapshot::updateOrCreate( + [ + 'apps_id' => $app->getId(), + 'companies_id' => $company->getId(), + 'agent_deployment_id' => $deployment->getId(), + 'snapshot_date' => now()->toDateString(), + 'source' => $source, + ], + [ + 'input_tokens' => $tokens['input_tokens'], + 'output_tokens' => $tokens['output_tokens'], + 'total_tokens' => $tokens['total_tokens'], + 'cache_read_tokens' => $tokens['cache_read_tokens'], + 'cache_write_tokens' => $tokens['cache_write_tokens'], + 'provider' => $provider, + 'model' => $model, + 'total_sessions' => 0, + 'raw_output' => '', + 'parsed_data' => $tokens, + ] + ); + } + + /** + * Resolve a deployment by ID first, falling back to container_name. + */ + private function resolveDeployment(?string $deploymentId, ?string $containerName): ?AgentDeployment + { + if (! empty($deploymentId)) { + /** @var AgentDeployment|null $deployment */ + $deployment = AgentDeployment::find((int) $deploymentId); + + if ($deployment !== null) { + return $deployment; + } + } + + if (! empty($containerName)) { + return AgentDeployment::where('container_name', $containerName) + ->where('is_deleted', 0) + ->first(); + } + + return null; + } + + /** + * Convert OTLP attribute list [{"key":"k","value":{"stringValue":"v"}}] + * into a flat key→value map. + * + * @param array> $attributes + * @return array + */ + private function extractAttributes(array $attributes): array + { + $result = []; + + foreach ($attributes as $attr) { + $key = $attr['key'] ?? ''; + + if ($key === '') { + continue; + } + + $valueWrapper = $attr['value'] ?? []; + $value = $valueWrapper['stringValue'] + ?? (string) ($valueWrapper['intValue'] ?? $valueWrapper['doubleValue'] ?? ''); + + $result[$key] = $value; + } + + return $result; + } + + /** + * Extract the first data-point value from a gauge or sum metric. + * Handles both intValue (string) and doubleValue. + */ + private function extractFirstDataPointValue(array $metric): int + { + $dataPoints = $metric['gauge']['dataPoints'] + ?? $metric['sum']['dataPoints'] + ?? []; + + if (empty($dataPoints)) { + return 0; + } + + $dp = $dataPoints[0]; + + return (int) ($dp['asInt'] ?? $dp['asDouble'] ?? 0); + } +} diff --git a/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php b/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php index fcee42d10d..a6420d5f03 100644 --- a/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php +++ b/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php @@ -16,23 +16,27 @@ public function __construct( public function execute(): AgentTypeModel { - return AgentTypeModel::updateOrCreate([ - 'apps_id' => $this->agentType->app->id, - 'name' => $this->agentType->name, - 'description' => $this->agentType->description, - 'provider' => $this->agentType->provider, - 'handler' => $this->agentType->handler, - 'config' => $this->agentType->config, - 'role' => $this->agentType->role, - 'soul' => $this->agentType->soul, - 'instructions' => $this->agentType->instructions, - 'output_format' => $this->agentType->output_format, - 'is_active' => $this->agentType->is_active, - 'is_published' => $this->agentType->is_published, - 'is_multi_agent' => $this->agentType->is_multi_agent, - 'is_default' => $this->agentType->is_default, - 'weight' => $this->agentType->weight, - 'multi_agent_list' => $this->agentType->multi_agent_list ?? [], - ]); + return AgentTypeModel::updateOrCreate( + [ + 'apps_id' => $this->agentType->app->id, + 'name' => $this->agentType->name, + ], + [ + 'description' => $this->agentType->description, + 'provider' => $this->agentType->provider, + 'handler' => $this->agentType->handler, + 'config' => $this->agentType->config, + 'role' => $this->agentType->role ?? '', + 'soul' => $this->agentType->soul, + 'instructions' => $this->agentType->instructions, + 'output_format' => $this->agentType->output_format, + 'is_active' => $this->agentType->is_active, + 'is_published' => $this->agentType->is_published, + 'is_multi_agent' => $this->agentType->is_multi_agent, + 'is_default' => $this->agentType->is_default, + 'weight' => $this->agentType->weight, + 'multi_agent_list' => $this->agentType->multi_agent_list ?? [], + ] + ); } } diff --git a/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php b/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php index b42ab8458d..580534aa3c 100644 --- a/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php +++ b/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php @@ -23,7 +23,7 @@ public function __construct( public ?string $soul = null, public ?string $instructions = null, public ?string $output_format = null, - public ?string $multi_agent_list = null, + public mixed $multi_agent_list = null, public bool $is_default = false, public int $weight = 0, ) { diff --git a/src/Domains/Intelligence/Agents/Models/Agent.php b/src/Domains/Intelligence/Agents/Models/Agent.php index f9b57db305..a708a6fd46 100644 --- a/src/Domains/Intelligence/Agents/Models/Agent.php +++ b/src/Domains/Intelligence/Agents/Models/Agent.php @@ -307,6 +307,13 @@ public function activeDeployment(): HasOne ->latestOfMany(); } + public function latestDeployment(): HasOne + { + return $this->hasOne(AgentDeployment::class) + ->where('is_deleted', 0) + ->latestOfMany(); + } + public function isContainerRuntime(): bool { if ($this->activeDeployment instanceof AgentDeployment) { diff --git a/src/Domains/Intelligence/Agents/Models/AgentType.php b/src/Domains/Intelligence/Agents/Models/AgentType.php index 4a8e17943d..d1bec62220 100644 --- a/src/Domains/Intelligence/Agents/Models/AgentType.php +++ b/src/Domains/Intelligence/Agents/Models/AgentType.php @@ -50,7 +50,7 @@ class AgentType extends BaseModel protected $fillable = [ 'uuid', - 'app_id', + 'apps_id', 'name', 'description', 'provider',