PR #3502
Sections
Review

fix(backend): stop routine tasks from getting office skills they can't run

main ← fix/routine-office-skills-gate 12 files +214 −18 PR #3502 ↗

Routine launches no longer receive bundled Office system skills that need Office runtime env; only Office-mode launches with KANDEV_CLI get them, while user skills still deploy everywhere.

Why this change

Routine tasks use the same skill deploy path as Office tasks. The deployer added every desired skill, including bundled system skills whose instructions need KANDEV_CLI and other Office env vars. Routine launches have no Office runtime env, so those skills break and waste context.

What it does

Architecture, end to end

The manager decides OfficeRuntime before the deployer builds the manifest. The manifest gate drops system skills for routine launches.

flowchart LR
  Launch[LaunchRequest McpMode + Env KANDEV_CLI] --> Manager[Manager.runSkillDeploy]
  Manager --> Check{Office mode and KANDEV_CLI?}
  Check -- yes --> RTTrue[OfficeRuntime true]
  Check -- no --> RTFalse[OfficeRuntime false]
  RTTrue --> Adapter[skillDeployerAdapter]
  RTFalse --> Adapter
  Adapter --> Deployer[skill.Deployer Deploy]
  Deployer --> Manifest[buildManifest]
  OfficeReader[office SkillReaderAdapter IsSystem] --> Manifest
  Manifest --> Gate[appendSkills IsSystem gate]
  Gate -- skip --> Drop[drop system skill]
  Gate -- keep --> Deliver[deliver to .agents/skills]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

type Skill struct
Click for details →

The runtime model now carries IsSystem so the deployer can tell bundled Office skills from user skills.

Skill struct
type Skill struct {
  Slug    string
  Content string
  Files   []SkillFile
  SourceType string
  // IsSystem marks a bundled Office skill (e.g. kandev-protocol,
  // kandev-task-ops) rather than a user-authored one. The deployer
  // includes system skills only when the launch has Office runtime support.
  IsSystem bool
}
Manifest gate drops system skills for routine launchesapps/backend/internal/agent/runtime/lifecycle/skill/manifest.go ↗
func (d *Deployer) appendSkills(ctx context.Context, manifest *Manifest, profile *settingsmodels.AgentProfile, officeRuntime bool)
Click for details →

The manifest builder skips system skills when OfficeRuntime is false and logs the skip at debug level.

Gate
func (d *Deployer) appendSkills(ctx context.Context, manifest *Manifest, profile *settingsmodels.AgentProfile, officeRuntime bool) {
  if d.skillReader == nil {
    return
  }
  for _, key := range mergedSkillKeys(profile) {
    skill, err := d.skillReader.GetSkillFromConfig(ctx, key)
    if err != nil || skill == nil {
      d.logger.Debug("skip skill in manifest", zap.String("key", key), zap.Error(err))
      continue
    }
    if skill.IsSystem && !officeRuntime {
      d.logger.Debug("skip system skill: no office runtime env", zap.String("key", key))
      continue
    }
    manifest.Skills = append(manifest.Skills, *skill)
  }
}
type Request struct
Click for details →

The deployer request now includes OfficeRuntime and passes it to buildManifest.

Request
type Request struct {
  Profile       *settingsmodels.AgentProfile
  WorkspacePath string
  ExecutorType  string
  WorkspaceID   string
  SessionID     string
  // OfficeRuntime reports whether backend selected Office mode and the
  // finalized launch env contains a non-empty KANDEV_CLI.
  OfficeRuntime bool
}
Deploy
func (d *Deployer) Deploy(ctx context.Context, req Request) (DeployResult, error) {
  if req.Profile == nil {
    return DeployResult{}, errors.New("skill deploy: profile is required")
  }
  manifest := d.buildManifest(ctx, req.Profile, d.workspaceSlugFn(req.WorkspaceID), req.OfficeRuntime)
  result := d.deliver(ctx, manifest, req.ExecutorType, req.WorkspacePath)
  return result, nil
}
Manager computes OfficeRuntime from launch envapps/backend/internal/agent/runtime/lifecycle/skill_deploy.go ↗
func (m *Manager) runSkillDeploy(ctx context.Context, original, prepared *LaunchRequest)
Click for details →

The manager sets OfficeRuntime only when McpMode is Office and KANDEV_CLI is non-empty after trim.

Compute flag
req := SkillDeployRequest{
  Profile:       profile,
  WorkspacePath: prepared.WorkspacePath,
  ExecutorType:  prepared.ExecutorType,
  WorkspaceID:   profile.WorkspaceID,
  SessionID:     original.SessionID,
  OfficeRuntime: prepared.McpMode == mcpmode.Office && strings.TrimSpace(prepared.Env["KANDEV_CLI"]) != "",
}
func (a *skillDeployerAdapter) DeploySkills(ctx context.Context, req SkillDeployRequest) (SkillDeployResult, error)
Click for details →

The adapter maps the lifecycle request to the runtime request and preserves OfficeRuntime.

Forward
func (a *skillDeployerAdapter) DeploySkills(ctx context.Context, req SkillDeployRequest) (SkillDeployResult, error) {
  if a.inner == nil {
    return SkillDeployResult{}, nil
  }
  res, err := a.inner.Deploy(ctx, skill.Request{
    Profile:       req.Profile,
    WorkspacePath: req.WorkspacePath,
    ExecutorType:  req.ExecutorType,
    WorkspaceID:   req.WorkspaceID,
    SessionID:     req.SessionID,
    OfficeRuntime: req.OfficeRuntime,
  })
  if err != nil {
    return SkillDeployResult{}, err
  }
  return SkillDeployResult{Metadata: res.Metadata, InstructionsDir: res.InstructionsDir}, nil
}
func (a *SkillReaderAdapter) GetSkillFromConfig(ctx context.Context, idOrSlug string) (*runtimeskill.Skill, error)
Click for details →

The office adapter copies IsSystem from the office model to the runtime model so the gate can read it.

Map IsSystem
return &runtimeskill.Skill{
  Slug:       sk.Slug,
  Content:    sk.Content,
  Files:      runtimeSkillFiles(sk.FileInventory),
  SourceType: string(sk.SourceType),
  IsSystem:   sk.IsSystem,
}, nil
Read the changes as a list

Runtime skill model marks system skills

apps/backend/internal/agent/runtime/lifecycle/skill/types.go

The runtime model now carries IsSystem so the deployer can tell bundled Office skills from user skills.

Skill struct
type Skill struct {
  Slug    string
  Content string
  Files   []SkillFile
  SourceType string
  // IsSystem marks a bundled Office skill (e.g. kandev-protocol,
  // kandev-task-ops) rather than a user-authored one. The deployer
  // includes system skills only when the launch has Office runtime support.
  IsSystem bool
}

Manifest gate drops system skills for routine launches

apps/backend/internal/agent/runtime/lifecycle/skill/manifest.go

The manifest builder skips system skills when OfficeRuntime is false and logs the skip at debug level.

Gate
func (d *Deployer) appendSkills(ctx context.Context, manifest *Manifest, profile *settingsmodels.AgentProfile, officeRuntime bool) {
  if d.skillReader == nil {
    return
  }
  for _, key := range mergedSkillKeys(profile) {
    skill, err := d.skillReader.GetSkillFromConfig(ctx, key)
    if err != nil || skill == nil {
      d.logger.Debug("skip skill in manifest", zap.String("key", key), zap.Error(err))
      continue
    }
    if skill.IsSystem && !officeRuntime {
      d.logger.Debug("skip system skill: no office runtime env", zap.String("key", key))
      continue
    }
    manifest.Skills = append(manifest.Skills, *skill)
  }
}

Deployer request carries OfficeRuntime

apps/backend/internal/agent/runtime/lifecycle/skill/deployer.go

The deployer request now includes OfficeRuntime and passes it to buildManifest.

Request
type Request struct {
  Profile       *settingsmodels.AgentProfile
  WorkspacePath string
  ExecutorType  string
  WorkspaceID   string
  SessionID     string
  // OfficeRuntime reports whether backend selected Office mode and the
  // finalized launch env contains a non-empty KANDEV_CLI.
  OfficeRuntime bool
}
Deploy
func (d *Deployer) Deploy(ctx context.Context, req Request) (DeployResult, error) {
  if req.Profile == nil {
    return DeployResult{}, errors.New("skill deploy: profile is required")
  }
  manifest := d.buildManifest(ctx, req.Profile, d.workspaceSlugFn(req.WorkspaceID), req.OfficeRuntime)
  result := d.deliver(ctx, manifest, req.ExecutorType, req.WorkspacePath)
  return result, nil
}

Manager computes OfficeRuntime from launch env

apps/backend/internal/agent/runtime/lifecycle/skill_deploy.go

The manager sets OfficeRuntime only when McpMode is Office and KANDEV_CLI is non-empty after trim.

Compute flag
req := SkillDeployRequest{
  Profile:       profile,
  WorkspacePath: prepared.WorkspacePath,
  ExecutorType:  prepared.ExecutorType,
  WorkspaceID:   profile.WorkspaceID,
  SessionID:     original.SessionID,
  OfficeRuntime: prepared.McpMode == mcpmode.Office && strings.TrimSpace(prepared.Env["KANDEV_CLI"]) != "",
}

Adapter forwards OfficeRuntime

apps/backend/internal/agent/runtime/lifecycle/skill_deployer_adapter.go

The adapter maps the lifecycle request to the runtime request and preserves OfficeRuntime.

Forward
func (a *skillDeployerAdapter) DeploySkills(ctx context.Context, req SkillDeployRequest) (SkillDeployResult, error) {
  if a.inner == nil {
    return SkillDeployResult{}, nil
  }
  res, err := a.inner.Deploy(ctx, skill.Request{
    Profile:       req.Profile,
    WorkspacePath: req.WorkspacePath,
    ExecutorType:  req.ExecutorType,
    WorkspaceID:   req.WorkspaceID,
    SessionID:     req.SessionID,
    OfficeRuntime: req.OfficeRuntime,
  })
  if err != nil {
    return SkillDeployResult{}, err
  }
  return SkillDeployResult{Metadata: res.Metadata, InstructionsDir: res.InstructionsDir}, nil
}

Office adapter maps IsSystem

apps/backend/internal/office/skills/runtime_adapter.go

The office adapter copies IsSystem from the office model to the runtime model so the gate can read it.

Map IsSystem
return &runtimeskill.Skill{
  Slug:       sk.Slug,
  Content:    sk.Content,
  Files:      runtimeSkillFiles(sk.FileInventory),
  SourceType: string(sk.SourceType),
  IsSystem:   sk.IsSystem,
}, nil

Data and storage

Two new booleans control the gate. No schema migration is needed; IsSystem comes from the existing office skill row.

FieldTypeNotes
Skill.IsSystembooltrue for bundled Office skills, false for user skills
Request.OfficeRuntimebooltrue only when McpMode is Office and KANDEV_CLI is set
SkillDeployRequest.OfficeRuntimeboollifecycle copy of the same flag, computed in runSkillDeploy

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Change is narrow: one boolean gate in appendSkills, plus plumbing.
  • Routine launches now get fewer skills, which reduces broken context, not more.
  • Covered by new unit tests for both Office and non-Office paths and an updated e2e.

Trade-offs and review notes

Where to look first

  1. Check appendSkills gate: IsSystem && !officeRuntime skips only system skills, user skills still pass.
  2. Check runSkillDeploy condition: Office mode plus trimmed KANDEV_CLI, not just mode alone.
  3. Check adapter chain: OfficeRuntime flows from Manager through adapter to Deployer without loss.
  4. Check office adapter: IsSystem copies correctly and tests assert it.