Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# OpenCompany Agent Guide

This file is the agent-facing operating guide for work inside the OpenCompany repository.

## Project Basics

- App URL for local development: `http://opencompany.test`
- Stack:
- Laravel 12
- Vue 3 + Inertia.js
- Tailwind CSS v4
- Reka UI primitives

## Workspace Rules

- OpenCompany is multi-workspace.
- Most data is workspace-scoped.
- Current workspace is resolved by middleware and available through `workspace()`.
- When adding queries, always scope them correctly.
- For models with `workspace_id`, use `forWorkspace()`.
- For related models, scope through the relationship with `whereHas(...)` or equivalent.

## Agent Runtime Notes

- The main runtime agent class is `app/Agents/OpenCompanyAgent.php`.
- Identity/system-prompt content is assembled from identity files and agent config, not from a static hardcoded prompt.
- The repo historically referenced `AGENTS.md` as an architectural concept, but this app currently stores agent instructions through its identity-file/document system.

## UI Rules

- Shared UI components live in `resources/js/Components/shared/`.
- Prefer wrapper components over raw elements when equivalents already exist.
- Dark mode exists and should not be broken by new UI work.

## MCP CLI

- MCP CLI is installed at `~/.local/bin/mcp-cli`.
- Config is at `~/.config/mcp/mcp_servers.json`.
- Common usage:
- `mcp-cli`
- `mcp-cli info <server>`
- `mcp-cli call <server> <tool> '<json>'`
- Connected servers currently include:
- `founder-mode`
- `notion`
- `vibe_kanban`
- `plane`

## Repo Conventions

- Prefer `rg` and `rg --files` for search.
- Keep edits targeted. Do not revert unrelated user changes.
- Do not patch `vendor/` for durable product work unless the task is explicitly temporary or exploratory.
- Put audits and investigations into markdown docs under `docs/`.

## Current Documentation Anchors

- Repo rules and local setup: `CLAUDE.md`
- Docs index: `docs/INDEX.md`
- Runtime audit: `docs/architecture/runtime-alignment-implementation-audit.md`
- Plane issue OC-1 investigation: `docs/architecture/plane-oc-1-investigation.md`
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@
- Shared components are in `resources/js/Components/shared/`
- Use the wrapper components (Button, Modal, Badge, etc.) instead of native elements for consistency
- Dark mode is supported via the `useColorMode` composable

## CLI Tools

### mcp-cli
- Installed at `~/.local/bin/mcp-cli` — a lightweight CLI for testing and calling MCP servers
- Config: `~/.config/mcp/mcp_servers.json`
- Usage: `mcp-cli` (list all), `mcp-cli info <server>` (details), `mcp-cli call <server> <tool> '<json>'` (call a tool)
- Connected servers: `founder-mode`, `notion`, `vibe_kanban`, `plane`
89 changes: 80 additions & 9 deletions app/Agents/OpenCompanyAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
use App\Models\TaskStep;
use App\Models\User;
use App\Services\AgentDocumentService;
use App\Services\Memory\ContextPruner;
use App\Services\Memory\PromptFrameBuilder;
use App\Services\Memory\ToolResultDeduplicator;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
Expand All @@ -21,12 +24,18 @@
use Laravel\Ai\Responses\Data\ToolCall;
use Laravel\Ai\Responses\Data\ToolResult;
use Illuminate\Support\Str;
use OpenCompany\PrismRelay\Contracts\HasSystemPrompts;

#[MaxTokens(16_384)]
class OpenCompanyAgent implements Agent, HasTools, Conversational
class OpenCompanyAgent implements Agent, HasTools, Conversational, HasSystemPrompts
{
use Promptable;

/**
* @var array<string, mixed>|null
*/
private ?array $promptFrameCache = null;

/** @var array<string, mixed> */
private array $resolvedProvider;

Expand All @@ -39,6 +48,9 @@ public function __construct(
private ChannelConversationLoader $conversationLoader,
private DynamicProviderResolver $providerResolver,
private ToolRegistry $toolRegistry,
private PromptFrameBuilder $promptFrameBuilder,
private ToolResultDeduplicator $toolResultDeduplicator,
private ContextPruner $contextPruner,
private ?string $taskId = null,
) {
$this->resolvedProvider = $this->providerResolver->resolve($this->agent);
Expand Down Expand Up @@ -79,11 +91,51 @@ public function resumeFrom(string $taskId): static
/**
* Get the instructions (system prompt) for this agent.
*
* Assembles from identity files in the same order as AgentChatService.
* Returns the full concatenated prompt (stable + volatile). When a
* SystemPromptBag is bound, CachingPrismGateway uses the split prompts
* from the bag instead for cache-friendly framing.
*/
public function instructions(): string
{
return implode('', array_column($this->buildSections(), 'content'));
return $this->promptFrame()['full_prompt'];
}

/**
* Get the full instruction set before stable/volatile splitting.
*/
public function fullInstructions(): string
{
return $this->promptFrame()['full_prompt'];
}

/**
* Get the volatile runtime context that should travel with the user prompt.
*/
public function volatilePromptContext(): string
{
return $this->promptFrame()['volatile_prompt'];
}

/**
* Runtime context now travels as additional system prompts via the gateway,
* so the user prompt should remain unchanged.
*/
public function preparePrompt(string $prompt): string
{
return $prompt;
}

/**
* @return string[]
*/
public function systemPrompts(): array
{
$frame = $this->promptFrame();

return array_values(array_filter([
trim($frame['stable_prompt']),
trim($frame['volatile_prompt']),
], fn (string $prompt) => $prompt !== ''));
}

/**
Expand All @@ -94,10 +146,27 @@ public function instructions(): string
*/
public function instructionsBreakdown(): array
{
return array_values(array_map(
fn (array $s) => ['label' => $s['label'], 'chars' => mb_strlen($s['content'])],
$this->buildSections(),
));
return $this->promptFrame()['stable_breakdown'];
}

/**
* @return array<int, array{label: string, chars: int}>
*/
public function volatileInstructionsBreakdown(): array
{
return $this->promptFrame()['volatile_breakdown'];
}

/**
* @return array<string, mixed>
*/
public function promptFrame(): array
{
if ($this->promptFrameCache !== null) {
return $this->promptFrameCache;
}

return $this->promptFrameCache = $this->promptFrameBuilder->splitSections($this->buildSections());
}

/**
Expand Down Expand Up @@ -228,7 +297,7 @@ private function injectPeerCards(array &$sections, Channel $channel): void
*/
public function messages(): iterable
{
$messages = $this->conversationLoader->load($this->channelId, $this->agent, $this->instructions());
$messages = $this->conversationLoader->load($this->channelId, $this->agent, $this->fullInstructions());

if ($this->resumeFromTaskId) {
$messages = $this->injectCheckpointedSteps($messages);
Expand Down Expand Up @@ -289,7 +358,9 @@ private function injectCheckpointedSteps(iterable $messages): array
);
}

return $messages;
$deduplicated = $this->toolResultDeduplicator->deduplicate($messages)['messages'];

return $this->contextPruner->prune($deduplicated)['messages'];
}

/**
Expand Down
10 changes: 4 additions & 6 deletions app/Agents/Providers/CodexPrismGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,16 @@
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Laravel\Ai\Gateway\Prism\PrismGateway;
use Laravel\Ai\Gateway\TextGenerationOptions;
use Laravel\Ai\Providers\Provider;
use OpenCompany\PrismRelay\Bridge\CachingPrismGateway;

/**
* Custom PrismGateway that routes Codex requests to the registered 'codex' Prism provider.
* Custom gateway that routes Codex requests to the registered 'codex' Prism provider.
*
* The Codex provider extends OpenAI and uses the same Responses API format, but routes
* requests through chatgpt.com/backend-api/codex/ using OAuth tokens from a ChatGPT
* Pro/Plus subscription — $0 token costs.
* Extends CachingPrismGateway for prompt cache support.
*/
class CodexPrismGateway extends PrismGateway
class CodexPrismGateway extends CachingPrismGateway
{
public function __construct(Dispatcher $events)
{
Expand Down
10 changes: 4 additions & 6 deletions app/Agents/Providers/GlmPrismGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Laravel\Ai\Gateway\Prism\PrismGateway;
use Laravel\Ai\Gateway\TextGenerationOptions;
use Laravel\Ai\Providers\Provider;
use OpenCompany\PrismRelay\Bridge\CachingPrismGateway;

/**
* Custom PrismGateway that routes requests to custom Prism providers
* Custom gateway that routes requests to custom Prism providers
* registered via PrismManager::extend() (GLM, Kimi, MiniMax, etc.).
*
* The base PrismGateway maps driver names to PrismProvider enums, which only
* works for native Prism providers. Custom providers need their string key
* passed directly to Prism's using() method.
* Extends CachingPrismGateway for prompt cache support on all providers.
*/
class GlmPrismGateway extends PrismGateway
class GlmPrismGateway extends CachingPrismGateway
{
public function __construct(Dispatcher $events)
{
Expand Down
33 changes: 25 additions & 8 deletions app/Agents/Tools/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Models\AppSetting;
use App\Models\User;
use App\Services\AgentPermissionService;
use OpenCompany\IntegrationCore\Support\ToolProviderRegistry;

class ToolRegistry
{
Expand Down Expand Up @@ -47,7 +46,6 @@ class ToolRegistry

public function __construct(
private AgentPermissionService $permissionService,
private ToolProviderRegistry $providerRegistry,
) {}

/**
Expand Down Expand Up @@ -94,7 +92,7 @@ private function getEffectiveToolMap(): array
}

// External integration providers
foreach ($this->providerRegistry->all() as $provider) {
foreach ($this->integrationProviders() as $provider) {
foreach ($provider->tools() as $slug => $meta) {
$this->effectiveToolMap[$slug] = $meta;
}
Expand All @@ -121,7 +119,7 @@ private function getEffectiveAppGroups(): array
}

// External integration providers
foreach ($this->providerRegistry->all() as $provider) {
foreach ($this->integrationProviders() as $provider) {
$meta = $provider->appMeta();
$this->effectiveAppGroups[$provider->appName()] = [
'tools' => array_keys($provider->tools()),
Expand All @@ -139,7 +137,7 @@ public function getEffectiveIntegrationApps(): array
{
if ($this->effectiveIntegrationApps === null) {
$this->effectiveIntegrationApps = self::INTEGRATION_APPS;
foreach ($this->providerRegistry->all() as $provider) {
foreach ($this->integrationProviders() as $provider) {
if ($provider->isIntegration() && ! in_array($provider->appName(), $this->effectiveIntegrationApps)) {
$this->effectiveIntegrationApps[] = $provider->appName();
}
Expand All @@ -161,7 +159,7 @@ private function getEffectiveAppIcons(): array
}

// External integration providers
foreach ($this->providerRegistry->all() as $provider) {
foreach ($this->integrationProviders() as $provider) {
$meta = $provider->appMeta();
$this->effectiveAppIcons[$provider->appName()] = $meta['icon'];
}
Expand All @@ -175,7 +173,7 @@ private function getEffectiveIntegrationLogos(): array
{
if ($this->effectiveIntegrationLogos === null) {
$this->effectiveIntegrationLogos = [];
foreach ($this->providerRegistry->all() as $provider) {
foreach ($this->integrationProviders() as $provider) {
$meta = $provider->appMeta();
if (isset($meta['logo'])) {
$this->effectiveIntegrationLogos[$provider->appName()] = $meta['logo'];
Expand Down Expand Up @@ -218,6 +216,11 @@ public function getToolMetaBySlug(string $slug): array
];
}

public function getToolTypeBySlug(string $slug): ?string
{
return $this->getEffectiveToolMap()[$slug]['type'] ?? null;
}

// ─── Tool filtering and instantiation ──────────────────────────────────

/**
Expand Down Expand Up @@ -582,7 +585,7 @@ private function instantiateTool(string $class, User $agent, string $slug = ''):
];

// Check external integration providers first
foreach ($this->providerRegistry->all() as $provider) {
foreach ($this->integrationProviders() as $provider) {
foreach ($provider->tools() as $toolSlug => $meta) {
if ($meta['class'] === $class && ($slug === '' || $toolSlug === $slug)) {
return $provider->createTool($class, [
Expand Down Expand Up @@ -630,4 +633,18 @@ private function buildAppLookup(): array

return $lookup;
}

/**
* @return array<int, object>
*/
private function integrationProviders(): array
{
$registryClass = \OpenCompany\IntegrationCore\Support\ToolProviderRegistry::class;

if (! class_exists($registryClass) || ! app()->bound($registryClass)) {
return [];
}

return app($registryClass)->all();
}
}
Loading
Loading