configTool function
The worst-case op (set) mutates a config file, so the tool declares
ApprovalTier.write. A failed op (unknown key, invalid value, refused
write) surfaces as an error: … text result — never an exception — so
the model always gets an actionable answer.
Implementation
AgentTool configTool(ConfigService service) {
Future<String> run(String op, Map<String, dynamic> arguments) async {
switch (op) {
case 'check':
return renderConfigCheckReport(await service.check());
case 'path':
return [
for (final info in await service.paths())
'${info.label}: ${info.path}${info.exists ? '' : ' (absent)'}',
].join('\n');
case 'get':
case 'set':
final key = arguments['key'] as String?;
if (key == null || key.isEmpty) {
throw ConfigException('"key" is required for op "$op"');
}
if (op == 'get') {
final result = await service.get(key);
if (result.notApplicable != null) {
return 'not applicable: ${result.notApplicable}';
}
return result.found
? '${result.key} = ${result.display} '
'(${result.scope}: ${result.file})'
: 'not set: $key';
}
final value = arguments['value'] as String?;
if (value == null || value.isEmpty) {
throw ConfigException('"value" is required for op "set"');
}
final scopeArg = arguments['scope'] as String?;
final scope = switch (scopeArg) {
'global' => ConfigScope.global,
'project' => ConfigScope.project,
_ => null,
};
final result = await service.set(key, value, scope: scope);
return '${result.key} = ${result.newDisplay} '
'(${result.scope}: ${result.file})\n'
'${result.application}';
}
throw ConfigException('unknown op: "$op" (expected check|path|get|set)');
}
return AgentTool(
name: 'config',
label: 'config',
tier: ApprovalTier.write,
// A batch of config calls reads and rewrites the same files — run the
// batch one call at a time or the read-modify-write cycles interleave
// (the e2e suite caught exactly that; issue #29 S4).
executionMode: ToolExecutionMode.sequential,
description:
'Inspect and edit the fa configuration — the user file '
'~/.fah/config.yaml and the project .fah/config.yaml — without a '
'shell (the same core the `fa config` CLI verbs wrap, available on '
'every host). ops: check = validate both files with the real '
'parsers (errors/warnings/notes + a final ok/failed line); '
'path = list the config file locations and whether each exists; '
'get = the effective value of a dotted key (project scope wins '
'where it participates); set = a surgical single-key write that '
'validates the edited file BEFORE writing, so an invalid value '
'persists nothing. Config keys are documented in the fa-self-config '
'skill — never invent one; unknown keys are rejected. A set result '
'names the file, scope, old -> new value, and whether the change '
'applies live or at next boot.',
parameters: const {
'type': 'object',
'properties': {
'op': {
'type': 'string',
'enum': ['check', 'path', 'get', 'set'],
'description': 'The config operation to run',
},
'key': {
'type': 'string',
'description':
'Dotted config key for get/set '
'(memory.projectPath, tools.web_search, provider, ...)',
},
'value': {
'type': 'string',
'description':
'The new value for set, rendered as a yaml scalar: '
'true/false verbatim, numbers verbatim, lists/maps as compact '
'JSON, strings plain or quoted',
},
'scope': {
'type': 'string',
'enum': ['global', 'project'],
'description':
'Write scope for set. Default resolves by key: '
'memory/cube/tools -> the project file (created minimal when '
'absent), everything else -> the user file',
},
},
'required': ['op'],
},
execute: (arguments, cancelToken, onUpdate) async {
cancelToken?.throwIfCancelled();
try {
return ToolExecutionResult.text(
await run(arguments['op'] as String, arguments),
);
} on ConfigException catch (error) {
return ToolExecutionResult.text('error: ${error.message}');
}
},
);
}