neoagent 3.0.1-beta.30 → 3.0.1-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,6 +85,26 @@ Open **Runs** to inspect the trigger, tool calls, approvals, output, and error
85
85
  for each execution. Delivery requires a configured messaging destination; a
86
86
  completed run can still have a delivery error.
87
87
 
88
+ ## Loop budgets
89
+
90
+ Scheduled and event-triggered tasks run autonomously under a per-task loop
91
+ budget. By default, a task can run 24 times or spend 250k model tokens per day.
92
+ At the cap, or when paused, the task is skipped before a model call.
93
+
94
+ Advanced task configs can override the defaults:
95
+
96
+ ```json
97
+ {
98
+ "loopBudget": {
99
+ "maxRunsPerDay": 12,
100
+ "maxTokensPerDay": 150000,
101
+ "paused": false
102
+ }
103
+ }
104
+ ```
105
+
106
+ Set `loopBudget.paused` or `loopPaused` to `true` as a per-task kill switch.
107
+
88
108
  ## Safety
89
109
 
90
110
  - Start with read-only integration accounts.
@@ -6076,6 +6076,7 @@ class NeoAgentController extends ChangeNotifier {
6076
6076
  required String triggerType,
6077
6077
  required Map<String, dynamic> triggerConfig,
6078
6078
  required String prompt,
6079
+ Map<String, dynamic>? taskConfig,
6079
6080
  String? model,
6080
6081
  bool enabled = true,
6081
6082
  String? agentId,
@@ -6087,6 +6088,7 @@ class NeoAgentController extends ChangeNotifier {
6087
6088
  triggerType: triggerType,
6088
6089
  triggerConfig: triggerConfig,
6089
6090
  prompt: prompt,
6091
+ taskConfig: taskConfig,
6090
6092
  model: model,
6091
6093
  enabled: enabled,
6092
6094
  agentId: agentId ?? _scopedAgentId,
@@ -3237,6 +3237,8 @@ class TaskItem {
3237
3237
  required this.triggerType,
3238
3238
  required this.triggerSummary,
3239
3239
  required this.triggerConfig,
3240
+ required this.taskConfig,
3241
+ required this.loopBudget,
3240
3242
  required this.nextRun,
3241
3243
  required this.prompt,
3242
3244
  required this.model,
@@ -3268,6 +3270,11 @@ class TaskItem {
3268
3270
  : const <String, dynamic>{}),
3269
3271
  };
3270
3272
  final triggerSummary = json['triggerSummary']?.toString() ?? '';
3273
+ final loopBudgetJson = json['loopBudget'] is Map
3274
+ ? Map<String, dynamic>.from(json['loopBudget'] as Map)
3275
+ : (taskConfig['loopBudget'] is Map
3276
+ ? Map<String, dynamic>.from(taskConfig['loopBudget'] as Map)
3277
+ : const <String, dynamic>{});
3271
3278
  return TaskItem(
3272
3279
  id: _asInt(json['id']),
3273
3280
  agentId: json['agentId']?.toString() ?? json['agent_id']?.toString(),
@@ -3277,6 +3284,8 @@ class TaskItem {
3277
3284
  ? 'Task trigger'
3278
3285
  : triggerSummary,
3279
3286
  triggerConfig: triggerConfig,
3287
+ taskConfig: taskConfig,
3288
+ loopBudget: TaskLoopBudget.fromJson(loopBudgetJson),
3280
3289
  nextRun: _parseOptionalTimestamp(json['nextRun']?.toString()),
3281
3290
  prompt:
3282
3291
  json['prompt']?.toString().ifEmpty(
@@ -3312,6 +3321,8 @@ class TaskItem {
3312
3321
  final String triggerType;
3313
3322
  final String triggerSummary;
3314
3323
  final Map<String, dynamic> triggerConfig;
3324
+ final Map<String, dynamic> taskConfig;
3325
+ final TaskLoopBudget loopBudget;
3315
3326
  final DateTime? nextRun;
3316
3327
  final String prompt;
3317
3328
  final String model;
@@ -3335,6 +3346,47 @@ class TaskItem {
3335
3346
  bool get isWidgetRefresh => taskType == 'widget_refresh';
3336
3347
  }
3337
3348
 
3349
+ class TaskLoopBudget {
3350
+ const TaskLoopBudget({
3351
+ required this.enabled,
3352
+ required this.paused,
3353
+ required this.maxRunsPerDay,
3354
+ required this.maxTokensPerDay,
3355
+ });
3356
+
3357
+ factory TaskLoopBudget.fromJson(Map<String, dynamic> json) {
3358
+ final maxRuns = _asInt(json['maxRunsPerDay'] ?? json['max_runs_per_day']);
3359
+ final maxTokens = _asInt(
3360
+ json['maxTokensPerDay'] ?? json['max_tokens_per_day'],
3361
+ );
3362
+ final enabled = json['enabled'] == false || json['enabled'] == 'false'
3363
+ ? false
3364
+ : true;
3365
+ return TaskLoopBudget(
3366
+ enabled: enabled,
3367
+ paused:
3368
+ json['paused'] == true ||
3369
+ json['pause'] == true ||
3370
+ json['paused'] == 'true' ||
3371
+ json['pause'] == 'true',
3372
+ maxRunsPerDay: maxRuns > 0 ? maxRuns : 24,
3373
+ maxTokensPerDay: maxTokens > 0 ? maxTokens : 250000,
3374
+ );
3375
+ }
3376
+
3377
+ final bool enabled;
3378
+ final bool paused;
3379
+ final int maxRunsPerDay;
3380
+ final int maxTokensPerDay;
3381
+
3382
+ Map<String, dynamic> toJson() => <String, dynamic>{
3383
+ 'enabled': enabled,
3384
+ 'paused': paused,
3385
+ 'maxRunsPerDay': maxRunsPerDay,
3386
+ 'maxTokensPerDay': maxTokensPerDay,
3387
+ };
3388
+ }
3389
+
3338
3390
  class WidgetSnapshotItem {
3339
3391
  const WidgetSnapshotItem({
3340
3392
  required this.id,
@@ -5296,6 +5296,24 @@ class _TasksPanelState extends State<TasksPanel> {
5296
5296
  }
5297
5297
  }
5298
5298
 
5299
+ String _compactBudgetNumber(int value) {
5300
+ if (value >= 1000000) {
5301
+ final millions = value / 1000000;
5302
+ return '${millions.toStringAsFixed(millions >= 10 ? 0 : 1)}M';
5303
+ }
5304
+ if (value >= 1000) {
5305
+ final thousands = value / 1000;
5306
+ return '${thousands.toStringAsFixed(thousands >= 10 ? 0 : 1)}k';
5307
+ }
5308
+ return '$value';
5309
+ }
5310
+
5311
+ String _taskBudgetLabel(TaskItem task) {
5312
+ final budget = task.loopBudget;
5313
+ return 'Budget: ${budget.maxRunsPerDay} runs/day · '
5314
+ '${_compactBudgetNumber(budget.maxTokensPerDay)} tokens/day';
5315
+ }
5316
+
5299
5317
  Future<void> _showLastRun(TaskItem task) async {
5300
5318
  final runId = task.lastRunId.trim();
5301
5319
  if (runId.isEmpty) return;
@@ -5512,6 +5530,10 @@ class _TasksPanelState extends State<TasksPanel> {
5512
5530
  label: task.enabled ? 'Active' : 'Paused',
5513
5531
  color: task.enabled ? _success : _textSecondary,
5514
5532
  ),
5533
+ if (task.loopBudget.paused) ...<Widget>[
5534
+ const SizedBox(width: 8),
5535
+ _StatusPill(label: 'Loop paused', color: _warning),
5536
+ ],
5515
5537
  if (task.hasLastRunStatus) ...<Widget>[
5516
5538
  const SizedBox(width: 8),
5517
5539
  _StatusPill(
@@ -5542,6 +5564,11 @@ class _TasksPanelState extends State<TasksPanel> {
5542
5564
  style: TextStyle(color: _textSecondary),
5543
5565
  ),
5544
5566
  const SizedBox(height: 8),
5567
+ Text(
5568
+ _taskBudgetLabel(task),
5569
+ style: TextStyle(color: _textSecondary),
5570
+ ),
5571
+ const SizedBox(height: 8),
5545
5572
  Text(task.prompt, style: TextStyle(color: _textPrimary)),
5546
5573
  if (task.lastRunLabel.isNotEmpty) ...<Widget>[
5547
5574
  const SizedBox(height: 8),
@@ -5631,6 +5658,10 @@ class _TasksPanelState extends State<TasksPanel> {
5631
5658
  label: task.enabled ? 'Active' : 'Paused',
5632
5659
  color: task.enabled ? _success : _textSecondary,
5633
5660
  ),
5661
+ if (task.loopBudget.paused) ...<Widget>[
5662
+ const SizedBox(width: 8),
5663
+ _StatusPill(label: 'Loop paused', color: _warning),
5664
+ ],
5634
5665
  if (task.hasLastRunStatus) ...<Widget>[
5635
5666
  const SizedBox(width: 8),
5636
5667
  _StatusPill(
@@ -5660,6 +5691,11 @@ class _TasksPanelState extends State<TasksPanel> {
5660
5691
  style: TextStyle(color: _textSecondary),
5661
5692
  ),
5662
5693
  const SizedBox(height: 8),
5694
+ Text(
5695
+ _taskBudgetLabel(task),
5696
+ style: TextStyle(color: _textSecondary),
5697
+ ),
5698
+ const SizedBox(height: 8),
5663
5699
  Text(
5664
5700
  linkedWidget.prompt,
5665
5701
  style: TextStyle(color: _textPrimary),
@@ -5841,7 +5877,15 @@ class _TasksPanelState extends State<TasksPanel> {
5841
5877
  text: task?.triggerConfig['sender']?.toString() ?? '',
5842
5878
  );
5843
5879
  final promptController = TextEditingController(text: task?.prompt ?? '');
5880
+ final loopBudget = task?.loopBudget ?? TaskLoopBudget.fromJson(const {});
5881
+ final maxRunsController = TextEditingController(
5882
+ text: loopBudget.maxRunsPerDay.toString(),
5883
+ );
5884
+ final maxTokensController = TextEditingController(
5885
+ text: loopBudget.maxTokensPerDay.toString(),
5886
+ );
5844
5887
  var enabled = task?.enabled ?? true;
5888
+ var loopPaused = loopBudget.paused;
5845
5889
  var unreadOnly = task?.triggerConfig['unreadOnly'] == true;
5846
5890
  var ignoreGroups = task?.triggerConfig['ignoreGroups'] == true;
5847
5891
  var selectedModel = _ensureModelValue(
@@ -6144,6 +6188,88 @@ class _TasksPanelState extends State<TasksPanel> {
6144
6188
  () => selectedModel = value ?? 'auto',
6145
6189
  ),
6146
6190
  ),
6191
+ const SizedBox(height: 12),
6192
+ Container(
6193
+ width: double.infinity,
6194
+ padding: const EdgeInsets.all(14),
6195
+ decoration: BoxDecoration(
6196
+ border: Border.all(
6197
+ color: _border.withValues(alpha: 0.8),
6198
+ ),
6199
+ borderRadius: BorderRadius.circular(12),
6200
+ ),
6201
+ child: Column(
6202
+ crossAxisAlignment: CrossAxisAlignment.start,
6203
+ children: <Widget>[
6204
+ Row(
6205
+ children: <Widget>[
6206
+ Icon(
6207
+ Icons.speed_outlined,
6208
+ size: 18,
6209
+ color: _textSecondary,
6210
+ ),
6211
+ const SizedBox(width: 8),
6212
+ Text(
6213
+ 'Loop budget',
6214
+ style: TextStyle(
6215
+ fontWeight: FontWeight.w700,
6216
+ color: _textPrimary,
6217
+ ),
6218
+ ),
6219
+ ],
6220
+ ),
6221
+ const SizedBox(height: 12),
6222
+ LayoutBuilder(
6223
+ builder: (context, constraints) {
6224
+ final stacked = constraints.maxWidth < 520;
6225
+ final fields = <Widget>[
6226
+ TextField(
6227
+ controller: maxRunsController,
6228
+ keyboardType: TextInputType.number,
6229
+ decoration: const InputDecoration(
6230
+ labelText: 'Max runs per day',
6231
+ ),
6232
+ ),
6233
+ TextField(
6234
+ controller: maxTokensController,
6235
+ keyboardType: TextInputType.number,
6236
+ decoration: const InputDecoration(
6237
+ labelText: 'Max tokens per day',
6238
+ ),
6239
+ ),
6240
+ ];
6241
+ if (stacked) {
6242
+ return Column(
6243
+ children: <Widget>[
6244
+ fields[0],
6245
+ const SizedBox(height: 12),
6246
+ fields[1],
6247
+ ],
6248
+ );
6249
+ }
6250
+ return Row(
6251
+ children: <Widget>[
6252
+ Expanded(child: fields[0]),
6253
+ const SizedBox(width: 12),
6254
+ Expanded(child: fields[1]),
6255
+ ],
6256
+ );
6257
+ },
6258
+ ),
6259
+ const SizedBox(height: 8),
6260
+ SwitchListTile(
6261
+ value: loopPaused,
6262
+ contentPadding: EdgeInsets.zero,
6263
+ title: const Text('Pause loop budget'),
6264
+ subtitle: const Text(
6265
+ 'Skip this task before it calls the model.',
6266
+ ),
6267
+ onChanged: (value) =>
6268
+ setLocalState(() => loopPaused = value),
6269
+ ),
6270
+ ],
6271
+ ),
6272
+ ),
6147
6273
  if (controller.agentProfiles.isNotEmpty) ...<Widget>[
6148
6274
  const SizedBox(height: 12),
6149
6275
  DropdownButtonFormField<String>(
@@ -6264,12 +6390,40 @@ class _TasksPanelState extends State<TasksPanel> {
6264
6390
  triggerConfig['ignoreGroups'] = ignoreGroups;
6265
6391
  }
6266
6392
  }
6393
+ final maxRuns = int.tryParse(maxRunsController.text.trim());
6394
+ final maxTokens = int.tryParse(
6395
+ maxTokensController.text.trim(),
6396
+ );
6397
+ if (maxRuns == null ||
6398
+ maxRuns <= 0 ||
6399
+ maxTokens == null ||
6400
+ maxTokens <= 0) {
6401
+ ScaffoldMessenger.of(context).showSnackBar(
6402
+ const SnackBar(
6403
+ content: Text(
6404
+ 'Loop budget values must be positive numbers.',
6405
+ ),
6406
+ backgroundColor: Colors.red,
6407
+ ),
6408
+ );
6409
+ return;
6410
+ }
6411
+ final taskConfig = <String, dynamic>{
6412
+ ...?task?.taskConfig,
6413
+ 'loopBudget': <String, dynamic>{
6414
+ 'enabled': true,
6415
+ 'paused': loopPaused,
6416
+ 'maxRunsPerDay': maxRuns,
6417
+ 'maxTokensPerDay': maxTokens,
6418
+ },
6419
+ };
6267
6420
  await controller.saveTask(
6268
6421
  id: task?.id,
6269
6422
  name: nameController.text.trim(),
6270
6423
  triggerType: selectedTriggerType,
6271
6424
  triggerConfig: triggerConfig,
6272
6425
  prompt: promptController.text.trim(),
6426
+ taskConfig: taskConfig,
6273
6427
  model: selectedModel == 'auto' ? null : selectedModel,
6274
6428
  enabled: enabled,
6275
6429
  agentId: selectedAgentId,