feat: add workspace-isolated toolhost runtime and capability-gap skill loop
This commit is contained in:
@@ -16,6 +16,12 @@ type Config struct {
|
||||
SoulPath string
|
||||
SkillsDir string
|
||||
ReactMaxSteps int
|
||||
ToolCallTimeoutSec int
|
||||
ToolOutputMaxChars int
|
||||
EnableCapabilityGap bool
|
||||
AutoSkillDir string
|
||||
GapDraftTriggerCount int
|
||||
GapClusterLookbackHours int
|
||||
|
||||
Telegram TelegramConfig
|
||||
Feishu FeishuConfig
|
||||
@@ -51,16 +57,25 @@ type SecurityConfig struct {
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
agentWorkspaceDir := resolveAgentWorkspaceDir()
|
||||
if err := preloadEnvFiles(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
defaultWorkSubdir := filepath.Join(agentWorkspaceDir, "workspace")
|
||||
defaultDataDir := filepath.Join(agentWorkspaceDir, "data")
|
||||
|
||||
cfg := Config{
|
||||
MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"),
|
||||
LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"),
|
||||
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), "./bot_context/soul.md"),
|
||||
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), "./skills"),
|
||||
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 4),
|
||||
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), filepath.Join(agentWorkspaceDir, "bot_context", "soul.md")),
|
||||
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
|
||||
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0),
|
||||
ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15),
|
||||
ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000),
|
||||
EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true),
|
||||
AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
|
||||
GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3),
|
||||
GapClusterLookbackHours: intFromEnv("GAP_CLUSTER_LOOKBACK_HOURS", 168),
|
||||
Telegram: TelegramConfig{
|
||||
Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
|
||||
PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30),
|
||||
@@ -77,11 +92,11 @@ func Load() (Config, error) {
|
||||
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
|
||||
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
|
||||
},
|
||||
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), "./data/laodingbot.db"),
|
||||
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
|
||||
Security: SecurityConfig{
|
||||
AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), "./workspace,./data")),
|
||||
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail")),
|
||||
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), "./workspace"),
|
||||
AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), strings.Join([]string{agentWorkspaceDir, defaultDataDir, defaultWorkSubdir}, ","))),
|
||||
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
|
||||
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -96,6 +111,18 @@ func Load() (Config, error) {
|
||||
if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 8 {
|
||||
return Config{}, fmt.Errorf("REACT_MAX_STEPS must be between 1 and 8")
|
||||
}
|
||||
if cfg.ToolCallTimeoutSec < 1 || cfg.ToolCallTimeoutSec > 300 {
|
||||
return Config{}, fmt.Errorf("TOOL_CALL_TIMEOUT_SEC must be between 1 and 300")
|
||||
}
|
||||
if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 {
|
||||
return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000")
|
||||
}
|
||||
if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 {
|
||||
return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100")
|
||||
}
|
||||
if cfg.GapClusterLookbackHours < 1 || cfg.GapClusterLookbackHours > 24*365 {
|
||||
return Config{}, fmt.Errorf("GAP_CLUSTER_LOOKBACK_HOURS must be between 1 and 8760")
|
||||
}
|
||||
|
||||
if cfg.MessageChannel == "telegram" {
|
||||
if cfg.Telegram.Token == "" {
|
||||
@@ -119,28 +146,152 @@ func Load() (Config, error) {
|
||||
return Config{}, fmt.Errorf("LLM_API_KEY is required")
|
||||
}
|
||||
|
||||
cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir)
|
||||
cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir)
|
||||
cfg.AutoSkillDir = resolvePathInWorkspace(cfg.AutoSkillDir, agentWorkspaceDir)
|
||||
cfg.SQLitePath = resolvePathInWorkspace(cfg.SQLitePath, agentWorkspaceDir)
|
||||
cfg.Security.WorkDir = resolvePathInWorkspace(cfg.Security.WorkDir, agentWorkspaceDir)
|
||||
cfg.Security.AllowedDirs = resolveDirsInWorkspace(cfg.Security.AllowedDirs, agentWorkspaceDir)
|
||||
cfg.Security.AllowedDirs = ensureAllowedDirs(cfg.Security.AllowedDirs,
|
||||
filepath.Clean(agentWorkspaceDir),
|
||||
filepath.Join(agentWorkspaceDir, "skills"),
|
||||
filepath.Join(agentWorkspaceDir, "data"),
|
||||
filepath.Join(agentWorkspaceDir, "workspace"),
|
||||
)
|
||||
cfg.Security.AllowedCommands = ensureAllowedCommands(cfg.Security.AllowedCommands, "go", "curl", "curl.exe")
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func preloadEnvFiles() error {
|
||||
paths := []string{}
|
||||
if explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE")); explicit != "" {
|
||||
paths = append(paths, explicit)
|
||||
}
|
||||
paths = append(paths, "configs/env", ".env")
|
||||
|
||||
for _, p := range paths {
|
||||
if err := loadEnvFile(p); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("load env file %s failed: %w", p, err)
|
||||
explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE"))
|
||||
if explicit != "" {
|
||||
if err := tryLoadEnvFile(explicit, true); err != nil {
|
||||
return fmt.Errorf("load env file %s failed: %w", explicit, err)
|
||||
}
|
||||
}
|
||||
|
||||
workspaceDir := resolveAgentWorkspaceDir()
|
||||
workspaceEnv := filepath.Join(workspaceDir, "configs", "env")
|
||||
workspaceDotEnv := filepath.Join(workspaceDir, ".env")
|
||||
if err := tryLoadEnvFile(workspaceEnv, true); err != nil {
|
||||
return fmt.Errorf("load env file %s failed: %w", workspaceEnv, err)
|
||||
}
|
||||
if err := tryLoadEnvFile(workspaceDotEnv, true); err != nil {
|
||||
return fmt.Errorf("load env file %s failed: %w", workspaceDotEnv, err)
|
||||
}
|
||||
|
||||
if err := tryLoadEnvFile("configs/env", false); err != nil {
|
||||
return fmt.Errorf("load env file %s failed: %w", "configs/env", err)
|
||||
}
|
||||
if err := tryLoadEnvFile(".env", false); err != nil {
|
||||
return fmt.Errorf("load env file %s failed: %w", ".env", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) error {
|
||||
func tryLoadEnvFile(path string, override bool) error {
|
||||
err := loadEnvFile(path, override)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveAgentWorkspaceDir() string {
|
||||
raw := strings.TrimSpace(os.Getenv("AGENT_WORKSPACE_DIR"))
|
||||
if raw == "" {
|
||||
raw = filepath.Join(".", "workspace", "agent_runtime")
|
||||
}
|
||||
abs, err := filepath.Abs(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func resolvePathInWorkspace(path, workspaceDir string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Clean(filepath.Join(workspaceDir, path))
|
||||
}
|
||||
|
||||
func resolveDirsInWorkspace(dirs []string, workspaceDir string) []string {
|
||||
out := make([]string, 0, len(dirs))
|
||||
for _, d := range dirs {
|
||||
resolved := resolvePathInWorkspace(d, workspaceDir)
|
||||
if strings.TrimSpace(resolved) != "" {
|
||||
out = append(out, resolved)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, filepath.Clean(workspaceDir))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureAllowedDirs(existing []string, dirs ...string) []string {
|
||||
set := map[string]struct{}{}
|
||||
out := make([]string, 0, len(existing)+len(dirs))
|
||||
for _, d := range existing {
|
||||
clean := filepath.Clean(strings.TrimSpace(d))
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[clean]; ok {
|
||||
continue
|
||||
}
|
||||
set[clean] = struct{}{}
|
||||
out = append(out, clean)
|
||||
}
|
||||
for _, d := range dirs {
|
||||
clean := filepath.Clean(strings.TrimSpace(d))
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[clean]; ok {
|
||||
continue
|
||||
}
|
||||
set[clean] = struct{}{}
|
||||
out = append(out, clean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureAllowedCommands(existing []string, commands ...string) []string {
|
||||
set := map[string]struct{}{}
|
||||
out := make([]string, 0, len(existing)+len(commands))
|
||||
for _, c := range existing {
|
||||
cmd := strings.ToLower(strings.TrimSpace(c))
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[cmd]; ok {
|
||||
continue
|
||||
}
|
||||
set[cmd] = struct{}{}
|
||||
out = append(out, cmd)
|
||||
}
|
||||
for _, c := range commands {
|
||||
cmd := strings.ToLower(strings.TrimSpace(c))
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[cmd]; ok {
|
||||
continue
|
||||
}
|
||||
set[cmd] = struct{}{}
|
||||
out = append(out, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadEnvFile(path string, override bool) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
absPath = path
|
||||
@@ -173,11 +324,14 @@ func loadEnvFile(path string) error {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
if err := os.Setenv(key, val); err != nil {
|
||||
return err
|
||||
if !override {
|
||||
if _, exists := os.LookupEnv(key); exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := os.Setenv(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -202,6 +356,20 @@ func intFromEnv(name string, d int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
func boolFromEnv(name string, d bool) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
|
||||
if raw == "" {
|
||||
return d
|
||||
}
|
||||
if raw == "1" || raw == "true" || raw == "yes" || raw == "on" {
|
||||
return true
|
||||
}
|
||||
if raw == "0" || raw == "false" || raw == "no" || raw == "off" {
|
||||
return false
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
Reference in New Issue
Block a user